Laboratoare PCom
Recomandări
Vă recomandăm să utilizați un IDE precum VS Code. Vom folosi Linux în cadrul laboratoarelor. Este recomandat ca înainte de orice laborator să parcurgeți lectura indicată. Laboratoarele și temele pot fi făcute în C sau C++. Laboratoarele pot fi navigate folosind săgețile de pe tastatură → ←.
Contribuții laborator
Oricine poate contribui pentru a îmbunătăți laboratoarele. Scheletul laboratoarelor se găsește pe Gitlab. Textul laboratoarelor îl găsiti în acest repo. În partea de sus dreaptă a fiecărei pagini există un buton de edit pe care îl puteți folosi pentru a sugera modificări.
Lectură laborator
De parcurs înainte de laborator:
Materiale video opționale:
- The first Internet connection, with UCLA's Leonard Kleinrock
- UCLA's Leonard Kleinrock displays Internet's first router.
Despre laboratoarele de PCom
Laboratoarele de PCom presupun cunoștințe despre USO, PC, SD și SO. În aceste laboratoare vom implementa și utiliza protocoalele de comunicație. Un protocol de comunicație este un set de reguli bine definite pe care interlocutorii trebuie să le urmeze în timpul comunicatiei.
Urmatorul slideshow prezinta ceea ce vom face la laboratoarele de PCom:
Înainte de fiecare laborator, vă recomandăm să parcurgeți lectură recomandată în prima parte a laboratorului. În cazul de față, video-ul intitulat Sending Digital Information over a Wire (durata de 4 minute). În general, timpul necesar pentru a studia lectură recomandată este de câteva minute.
Exercițiile din laboratoare pot fi rezolvate atât în C cât și C++, cu toate aceste recomandăm implementări în versiuni moderne de C++. Recomandăm Visual Studio Code cu extensia de C/C++ pentru laboratoare și teme. Atât temele cât și laboratoarele se vor face pe Linux.
Nivelul fizic
Nivelul fizic se referă la protocoalele și tehnicile utilizate pentru a permite schimbul de informații. Schimbul de informații se face peste un mediu de transmisie (link).
Exemple de medii de transmisie:
- wireless
- cablu electric
- fibră optică
- semnale de fum
În cazul comunicației prin cablu, nivelul fizic se ocupă cu codificarea biților în semnale electrice. Un exemplu de codificare este următoarea:
Transmițător (Sender):
- la fiecare milisecundă cablul electric va fi conectat la 5V pentru a transmite bitul 1 și la 0V pentru a transmite bitul 0.
Receptor (Receiver):
- la fiecare milisecundă va măsura tensiunea de pe fir
Rata de transmisie (bit rate) reprezintă numărul de biți transmiși pe secundă. În exemplul cu cablul electric, rata de transmisie este de 1000 de biți pe secundă.
Internetul
Pe baza celor discutate în secțiunea precedentă, pe parcursul laboratorului vom pune bazele internetului pe care cu toții îl folosim astăzi.
La începutul anilor 1970 internetul se rezumă la comunicarea peste un cablu între două dispozitive printr-un protocol simplu, dar în doar câțiva ani complexitatea a crescut enorm. În figura de mai jos vedem precursorul internetului de astăzi, ARPANET.
Pentru a modela cât mai ușor arhitectura Internetului, cercetătorii de la acea vreme au propus diferite modele de referință. Dintre acestea, Open Systems Interconnection (OSI), modelul propus de Huber Zimmerman, a fost cel mai influent. Totuși, în practică, modelul dominant de referință folosit este TCP/IP. În figura de mai jos, putem vedea cele două modele de referință și o serie de protocoale la fiecare nivel. La laborator, vom avea o abordare bottom-up. Vom porni de la protocoale simple pentru a conecta două calculatoare și vom ajunge să implementăm protocoale de nivel aplicație, precum HTTP, folosit astăzi mai ales de web browsers. În general, protocoalele sunt descrise în documente numite Request for Comment (RFC). De exemplu, RFC 791 descrie protocolul IP.
Networking de mână
Accesarea unei pagini Web
1. În orice browser, accesați website-ul http://example.com/
2. Vom realiza același lucru de mână:
- Din CLI, rulați
telnet example.com 80. Această comandă deschide o conexiune (flux de octeți) către un alt calculator, example.com, care funcționează ca un server și rulează un serviciu HTTP (Hypertext Transfer Protocol), folosit de World Wide Web. Dacă totul decurge conform planului, veți vedea mesajulConnected to example.com.. - Scrieți
GET / HTTP/1.1. Aceasta reprezintă acțiunea dorită (GET), calea către resursa pe care dorim să o accesăm (/) și versiunea de protocol folosită (HTTP/1.1) - Scrieți
Host: example.com- Specifică gazda, de la care dorim să accesăm resursa. - Scrieți
Connection: close- Specifică serverului că nu vom mai trimite cereri după aceasta. - Apăsați
Enter. Acesta trimite o linie goală care reprezintă faptul că am terminat cu cererea HTTP. - Dacă totul decurge conform planului, veți vedea un răspuns similar cu cel pe care l-ați văzut într-un browser.
La finalul cursului de PCom, veți înțelege ce s-a întâmplat în spatele acestor comenzi.
Server simplu
Am văzut că telnet poate acționa ca un client care se conectează la un server. Acum vom vedea cum implementam propriul nostru server.
- Într-un terminal vom rula
netcat -v -l -p 9090pentru a porni un server simplu care ascultă pe portul 9090. - În alt terminal, vom rula
telnet localhost 9090pentru a ne conecta local, pe calculator, la serverul care rulează pe portul 9090. - Dacă totul decurge conform planului, vom vedea în al doilea terminal următorul
mesaj:
Connection from localhost 53500 received!. - În oricare dintre ferestre, putem scrie orice, și după ce apăsăm "Enter", acesta ajunge și în partea cealaltă.
Observăm astfel că avem mai multe procese care pot implementa funcționalități de networking.
Monitorizarea traficului
Pentru o mai bună înțelegere a protocoalelor pe care le folosim și pe care urmează să le dezvoltăm, vom folosi două aplicații pentru a captura traficul și a îl analiza: Wireshark și tcpdump. Aceste două unelte vor fi esențiale pe parcursul materiei.
Monitorizarea traficului
Wireshark
Wireshark este o aplicație folosită pentru analiza traficului de rețea de pe un dispozitiv. Acesta se găsește în majoritatea repository-urilor și poate fi instalat astfel:
sudo apt install wireshark
Pe Linux întâlnim conceptul de network interface. De exemplu eth0 este
interfață pentru Ethernet (cablu), iar wlp0s20f3 este de wireless. Pentru a
putea intercepta traficul pe o astfel de interfață trebuie să rulăm ca root.
sudo wireshark
Wireshark are o interfață simplă -- pe fereastra ce se deschide vor apărea
interfețele disponibile. Pe calculatorul pe care a fost făcut screenshot-ul, apar
mai multe interfețe precum wlp0s20f3, vmnet, etc.
Pentru a începe capturarea traficului de pe o interfață, trebuie să selectăm
interfața, după care să dăm click pe butonul Capture din stânga sus.
Pachetele capturate apar în prima fereastră. Aici putem vedea diverse informații, precum adresa sursă, protocolul, timpul la care a fost capurat pachetul. Dacă dăm click pe un pachet, în fereastră de la mijloc vor apărea toate informațiile despre acesta. În cazul de față, vedem un pachet compus de trei protocoale: Ethernet, Internet Protocol 4 (IPv4) și Transmission Control Protocol (TCP). În fereastră, am extins doar conținutul TCP al pachetului. Fereastră de jos indică conținutul pachetului sub formă de octeți.
Bară de filtrare. Wireshark permite filtrarea pachetelor: în bara unde
apare Apply a display filter putem adaugă filtre precum:
// Doar pachete ce au adresa IP sursa sau destinatia 192.0.2.1
ip.addr == 192.0.2.1
// Doar pachete tcp sau udp ce folosesc portul 80
// In laboratorul 6 vom discuta despre porturi
tcp.port == 80 || udp.port == 80
tcpdump
Tcpdump este un utilitar de Linux care permite interceptarea și afișarea traficului de rețea direct din terminal.
❯ sudo tcpdump -i wlp0s20f3
dropped privs to tcpdump
tcpdump: verbose output suppressed, use -v[v]... for full protocol decode
listening on wlp0s20f3, link-type EN10MB (Ethernet), snapshot length 262144 bytes
00:16:49.089497 IP6 thinky.41894 > sof02s17-în-x0a.1e100.net.https: Flags [P.], seq 2546843426:2546843465, ack 2356704842, win 501, options [nop,nop,TS val 943111274 ecr 2415067731], length 39
00:16:49.136836 IP6 sof02s17-în-x0a.1e100.net.https > thinky.41894: Flags [.], ack 39, win 289, options [nop,nop,TS val 2415125795 ecr 943111274], length 0
Note
Pentru afla interfețele disponibile pe o mașină ce rulează Linux putem folosi următoarea comandă:
ip address show
tcpdump permite utilizarea filtrelor precum:
# Afișează doar pachete ce au ca IP sursă sau destinație 8.8.8.8
tcpdump host 8.8.8.8
# Afișează doar pachete ce au ca port destinație 80
tcpdump src port 80
Tcpdump poate salva traficul interceptat într-un fișier .pcap ce poate fi deschis cu Wireshark.
tcpdump -w traffic.pcap
Noțiuni generale de programare
Vă recomandăm următorul crash course de C pentru a vă reaminti concepte precum structurile, pointerii și alocarea de memorie.
Dacă alegeți să lucrați în C++, vă recomandăm acest ghid despre cum să scrieți cod mai simplu, mai eficient și mai ușor de întreținut.
Compilare
Cel mai popular compilator de C/C++ este gcc. Să presupunem că avem un
fișier sursă numit hello.c. Compilarea standard a acestui fișier se face
folosind gcc hello.c.
Ca rezultat se obține un fișier a.out care se poate executa. Dacă însă
compilăm folosind gcc hello.c -o hello se va obține fișierul executabil
hello.
Pentru un cod scris in C++, .cpp, vom folosi g++.
- Opțiunea -g va permite obținerea unui executabil care va conține informații de debug. Această opțiune este utilă în combinație cu un program de debugging cum ar fi gdb.
NOTĂ
Pentru a detecta accesările invalde la memorie, vom compila programul nostru cu
address sanitization (ASAN) folosind următorul flag: -fsanitize=address. Mai
mult, pentru a evita undefined behavior vom folosi -fsanitize=undefined.
Aceste opțiuni sunt utile pentru a ne asigura că programul scris de noi nu are
bug-uri ascunse. Recomandăm rularea codului cu aceste opțiuni înainte de a face
submisii la teme.
Debugging
Cum networking-ul deseori înseamnă programare low level, ne vom lovi de
probleme precum Segmentation Fault. În cazul în care nu putem identifica
problema folosind valgrind, printf debugging, sau opțiunile de sanitizare,
va trebui să folosim un debugger.
Va recomandăm articolul Debugging pentru a va reaminti de cum folosim GDB atât din CLI cât și prin intermediul unui IDE.
File descriptors
În a doua jumătate a laboratorului vom folosi API-ul de POSIX sockets și noțiunea de file descriptor (fd). Simplist, un file descriptor este un număr întreg ce reprezintă un identificator în tabela de fișiere a unui program. De exemplu, dacă deschidem un fișier, identificatorul ar putea fi numărul 4. Fiecare proces are propria sa tabelă de file descriptors.
Note
Un program are inițial 3 file descriptors: 0 - stdin, 1 - stdout si 2 -
stderr. În general, pentru primul fisier pe care il deschidem cu open, o sa
primim ca file descriptor valoarea 3.
Exemplu
Avem un exemplu de folosire a funcțiilor de acces la fișiere în programul de mai jos, care copiază fișierul ”sursă” în fișierul ”destinație”. Urmăriți comentările pentru a înțelege mai bine funcționalitatea.
#include <unistd.h> /* pentru open(), exit() */
#include <fcntl.h> /* O_RDWR */
#include <stdio.h> /* perror() */
#include <errno.h>
#include <stdlib.h>
void fatal(char * mesaj_eroare)
{
perror(mesaj_eroare);
exit(0);
}
int main(void)
{
int source_fd, dest_fd;
int bytes_count;
char buf[1024];
/* Open primeste ca argumente path-ul catre un fisier
si un flag, in cazul acesta flag-ul speicica ca urmeaza
doar sa citim din fisier.
*/
source_fd = open("/path/to/source", O_RDONLY);
/* O_CREAT este un flag special care specifica faptul ca daca
fisierul nu exista, vom creea unul noi cu permisiunile 0644
*/
dest_fd = open("/path/to/destination", O_WRONLY | O_CREAT, 0644);
/* Daca open returneaza o valoare mai mica de 0, atunci inseamna
ca avem o eroare */
if (source_fd < 0 || dest_fd < 0)
fatal("Nu pot deschide un fisier");
/* Fisierele sunt acuma identificate prin cele 2 file descriptors,
source_fd si dest_fd
*/
/* Cu ajutorul functie read citim din fisier, in cazul de fata
citim din fisierul identificat prin source_fd date
si le punem in buf. Citim maxim sizeof(buf), adica 1024 */
while ((bytes_count = read(source_fd, buf, sizeof(buf)))) {
/* read returneaza numarul de bytes cititi */
if (bytes_count < 0)
fatal("Eroare la citire");
/* read muta si ceea ce numim cursorul, daca de exemplu
fisierul are 2048 de bytes, primul apel de read va muta
cursorul pe pozitia 1024, o citire ulterioara va returna
date de la pozitia 1024 in sus. lseek este o functie speciala
pentru a interactiona cu cursorul
*/
/* write este similar cu read */
bytes_count = write(dest_fd, buf, bytes_count);
if (bytes_count < 0)
fatal("Eroare la scriere");
}
/* Este good practice sa eliberam file descriptors */
close(source_fd);
close(dest_fd);
return 0;
}
NOTĂ
O bună practică pe care o recomandăm este să verificați valoarea de retur pentru orice apel de funcție și să afișați mesaje corespunzătoare de eroare.
Exerciții
1. Vrem să ne pregătim pentru a trimite date în format binar
peste un mediu de transmisie. În fișierul de
aici
avem un array de structuri de tipul Packet în format binar. Realizați un
program în C/C++ care să citească array-ul cu elemente de tip Packet din
acest fișier și să afișeze conținutul din payload al fiecărei intrări.
Procesul prin care acest fișier a fost creat se numește serializare și
îl vom întâlni atunci când vom serializa datele pentru a fi trimise spre rețea.
Procesul invers, pe care îl veți implementa, se numește deserializare.
struct Packet {
char payload[100];
int sum;
int size;
};
2. Vom folosi telnet -4 telehack.com pentru a ne conecta
la un server TCP. In prompt o să scriem starwars si enter.
Acest server trimite un șir de biți către noi, iar telnet
îl afișează pe ecran (stdout). Dacă totul a mers bine, ar trebui să vedeți
prima scenă din Star Wars în format ASCII.
3. Folosind Wireshark vom analiză traficul generat de către comanda
telnet de la exercițiul precedent. Identificați un pachet ce conține bucăți
din textul ce apare în terminal.
Note
În exemplele de filtre pe care le-am văzut până acuma erau utilizate doar
adrese IP (e.g. ip.addr == 127.0.0.1). Pentru a afla adresa ip a
telehack.com vom folosi host telehack.com.
4. Pentru a ne obișnui cu programarea low level, scrieți un utilitar
similar cu cat în C/C++. Acesta trebuie să afișeze conținutul unui fișier,
linie cu linie, la stdout. Vom folosi API-ul direct peste file
descriptors (e.g. read, open).
Lectură laborator
De parcurs înainte de laborator:
Materiale video opționale:
Framing
În general, nu suntem interesați în a lucra cu date la nivel de biți. Aplicațiile pe care le dezvoltăm lucrează cu mesaje, structuri sau fișiere complete. Nivelul fizic ne permite să transmitem un flux de biți de la un dispozitiv la altul, dar datele pe care le transmitem sunt structurate în blocuri la nivel logic.
Receptorul trebuie să știe să delimiteze între aceste blocuri pentru a extrage datele corect. Cum nivelul fizic nu este ideal, pot apărea probleme precum desincronizări, astfel că soluția naivă în care spunem că fiecare 8 biți reprezintă un frame nu este valabilă.
010|01000001|01000010|10101
'A' 'B'
Unitatea de informație pe care o vom folosi la nivelul DataLink este cadrul (frame) și reprezintă fluxul de biți care constituie un bloc logic de date.
Note
Problema pe care încercăm să o rezolvăm este: Cum face expeditorul (sender) codificarea cadrelor (frames) astfel încât receptorul (receiver) să le poată extrage eficient din fluxul de biți pe care îl primește de la nivelul fizic?
Bit stuffing
O posibilă metodă de framing o reprezintă bit stuffing. Vom folosi
01111110 ca și delimitator de cadre.
De exemplu, dacă vrem să trimitem 0100 atunci o să îl codăm ca și
01111110|0100|01111110. Receptorul, doar după ce a primit 0111110 va începe
să citească conținutul cadrului.
Ce facem în cazul în care vrem să trimitem 6 biți de 1, 111111? Regula este
simplă, după fiecare 11111, se inserează un 0. Astfel, delimitatorul
01111110 nu o să apară niciodată în conținutul unui cadru.
Sender
111111 -> 1111101
Receiver
1111101 -> 111111
1111100 -> 111110
Putem dezvolta astfel un protocol foarte simplu de nivel 2. Specificatia acestui protocol (un fel de RFC) conține structura cadrului și regula definită pentru a nu întâlnii delimitatorul în datele pe care le vom transmite (payload).
DELIM|PAYLOAD|DELIM
Character stuffing în practică
Cum în software ne este mult mai ușor să lucrăm la nivel de byte decât bit,
nivelul fizic ne oferă și un serviciu de trimitere de fluxuri de bytes. În mod
similar cu bit stuffing, vom folosi mai multe caractere speciale pentru a ne
delimita frame-ul. Vom folosi DLE, STX și ETX definiți în tabela
ASCII
A B C => DLE STX A B C DLE ETX
A B C DLE STX D => DLE STX A B C DLE DLE STX D DLE ETX
Mai jos avem o diagramă care surprinde transmisia de date folosind framing. Vedem cum la nivelul DataLink folosind protocolul nostru simplu cu bytes de separare putem oferi un serviciu de trimitere de frames.
Următorul exemplu prezintă o posibilă implementare de character stuffing
folosind DLE,STX și ETX. Presupunem că am cumpărat o placă de
rețea (NIC) care are în firmware două funcții send_byte și
recv_byte. În general, implementarea unui protocol se face într-o bibliotecă
pe care atât programul ce rulează la transmițător cât și cel de la receptor
o folosesc.
Transmiterea este relativ simplă.
/* Transmitem date aflate în buffer */
/* Trimite delimitator */
send_byte(DLE);
send_byte(STX);
/* Trimite bytes din frame */
for (int i = 0; i < size; i++) {
/* Facem escape la escape */
if (buffer[i] == DLE)
send_byte(DLE);
send_byte(buffer[i]);
}
/* Trimite delimitator final */
send_byte(DLE);
send_byte(ETX);
Recepția cadrului are o complexitate mai mare, deoarce în exemplul nostru
recv_byte întoarce date aleatoare când transmițătorul nu trimite nimic.
char c1, c2;
c1 = recv_byte();
c2 = recv_byte();
/* Cât timp nu am primit DLE STX citim bytes. Atenție la modul în care salvăm
* byte-ul precedent. */
while( ((c1 != DLE) && (c2 != STX)) || (c1 == DLE && c2 != STX) \
|| (c1 != DLE && c2 == STX)) {
c1 = c2;
c2 = recv_byte();
}
/* Am primit începutul unui frame: DLE STX */
for (int i = 0; i < max_size; i++) {
char byte = recv_byte();
/* Dacă am primit un escape */
if (byte == DLE) {
byte = recv_byte();
/* Am primit DLE ETX */
if (byte == ETX)
return i;
/* După DLE, trebuie să primim alt DLE,
altfel frame-ul nu este bine structurat */
else if (byte != DLE)
return -1;
}
/* Punem în buffer conținutul frame-ului */
buffer[i] = byte;
}
Tipuri de comunicatie
În funcție de mediu, putem avea un receptor sau mai mulți. Comunicarea poate fi clasificată astfel în: Point-to-Point și Point-to-Multipoint.
Point-to-Point
Comunicarea Point-to-Point se întâmplă atunci când avem doar două dispozitive. În acest caz, dispozitivele nu trebuie să specifice cui vor să trimită frame-urile.
În harta de mai jos, putem vedea legăturile Point-to-Point dintre mai multe dispozitive de la acea vreme (Decembrie 1970): de exemplu, STANFORD-UTAH.
Exemple de protocoale de nivel 2 dezvoltate pentru comunicare Point-to-Point: Point-to-Point Protocol (PPP), High-Level Data Link Control (HDLC)*.
Point-to-Multipoint
Într-o transmisie de tip Point-to-Multipoint, avem un transmitator și mai mulți receptor. Cel mai popular mod de a identifica destinația este de a include un câmp de identificare în antetul protocolului (de exemplu, adresa MAC în Ethernet). În imaginea de mai jos sunt două exemple de comunicații multipoint.
Metrici
Pentru a putea studia performanța unui protocol de nivel DataLink, ne interesează următoarele metrici:
- Bandwidth - se măsoară în biți / secunda și reprezinta cantitatea de informație care poate fi transmisa într-o unitate de timp pe legătura de date
- Delay - se măsoară în secunde și reprezinta timpul care le ia unor date trimise printr-un mediu să ajungă la destinație
- Bandwidth delay product (BDP) - reprezinta numarul total de biti ce se pot afla pe un link la un anumit moment de timp
Legătura de date poate fi asemănata cu un cilindru în care datele sunt introduse de către transmițător și primite de către receptor. Aria secțiunii cilindrului reprezinta viteza de transmisie, iar înălțimea este timpul de propagare. Volumul cilindrului determina cantitatea de informație aflată pe legătura de date, la un anumit moment de timp. Deci, cantitatea de informație aflata pe link la un anumit moment de timp este: Bandwidth × Delay.
Tabelul de mai jos prezinta mai multe metrici pentru link-uri existente.
| Tip Link | Bandwidth | One-Way Distance | Delay | Bandwidth x Delay (BDP) |
|---|---|---|---|---|
| Wireless LAN | 54 Mbps | 50 m | 0.15 us | 18 bits |
| Satellite | 1 Gbps | 35000 km | 115 ms | 230 Mb |
| Cross-country fiber | 10 Gbps | 4000 km | 40 ms | 400 Mb |
Exerciții
În cele ce urmează vom punele bazele unui mic protocol de nivel DataLink peste un mediu fizic ideal (fără pierderi/coruperi).
Scheletul laboratorului se găsește aici, tot acolo se găsește și un README.md cu funcționalitatea disponibilă.
1. Vrem să implementăm un protocol de nivel DataLink care folosește tehnică byte
stuffing pentru a trimite șiruri de caractere ca payload. La acest exercițiu o
să presupunem o transmisie de tip Point-to-Point, așa că nu trebuie să
specificăm destinația în protocol. Pentru simplitate, payload-ul va avea mereu
30 de bytes și nu vom include un câmp size în header. Frame-ul îl putem
structura astfel:
/* Atributul este folosit pentru a anunță compilatorul să nu alinieze structura */
/* DELIM | DATE | DELIM */
struct __attribute__((packed)) Frame {
char frame_delim_start[2]; /* DEL STX */
char payload[30]; /* Datele pe care vrem să le transmitem */
char frame_delim_end[2]; /* DEL ETX */
};
NOTE
Executați scriptul ./run_experiment.sh pentru a observă un demo al
funcționalitaților disponibile. Acesta pornește un simulator de nivel
fizic și rulează ./recv și ./send.
Un șir de caractere precum char buffer[1024] reprezintă un șir de bytes,
astfel putem face operații precum ((struct Frame) buffer)->frame_delim_start[0],
memcpy(buffer, &frame, sizeof(struct Frame)) sau send_byte(((char *) frame));
2. Acum că am reușit să trimitem primele noastre frame-uri care conțin șiruri de caractere, vrem să extindem protocolul pentru a funcționa într-un mediu de transmisie Point-to-Multipoint. Avem mai multe dispozitive legate la același cablu (de exemplu, dispozitivele care măsoară tensiunea de pe cablu) și fiecare este identificat printr-un întreg.
Extindem protocolul nostru prin adăugarea a două noi câmpuri, sursă și destinație. Structura de date de mai jos reprezintă conținutul frame-ului pe care vrem să-l transmitem. Adaptați atât implementarea protocolului din sender cât și din receiver pentru a funcționa peste noile constrângeri.
/* Atributul este folosit pentru a anunță compilatorul să nu alinieze structura */
/* DELIM | SOURCE | DEST | PAYLOAD | DELIM */
struct __attribute__((packed)) Frame {
char frame_delim_start[2]; /* DEL STX */
int source; /* Identificator SURSĂ */
int dest; /* Identificator DESTINAȚIE */
char payload[30]; /* Datele pe care vrem să le transmitem */
char frame_delim_end[2]; /* DEL ETX */
};
În simulatorul nostru avem o conexiune PPP, la acest exercițiu doar vom verifica în receiver câmpul destinație. Protocolul Point-to-Point Protocol face ceva similar, are în header un câmp address ce nu este folosit :).
3. Vrem să măsurăm delay-ul pentru a transmite un frame de 100 bytes și unul de 300. În sender vom trimite un frame ce conține un timestamp, în receiver vom măsura latența (diferența de timp între timestamp și timpul curent). Ce observăm? (Puteți folosi orice metodă de măsurare a timpului, precum cele prezente în articolul următor.
Lectură laborator
De parcurs înainte de laborator:
Materiale video optionate:
Detectarea erorilor de transmisie
În timpul transmiterii de date, pot apărea erori. Acestea se pot manifesta ca biți ale căror valori sunt schimbate într-un cadru. Întâlnim două tipuri de erori la nivelul legăturii de date:
- cadrele pot fi corupte
- cadrele pot fi pierdute sau pot apărea cadre neașteptate
De exemplu, dacă trimitem șirul de biți 11111111 prin intermediul unui cablu,
din cauza interferențelor electromagnetice, ultimul bit ar putea avea valoarea
schimbată și receptorul ar primi 11111110.
În general, pentru a putea transmite date peste link imperfect, vom folosi una dintre următoarele abordări:
- detectarea erorilor și retransmisia (e.g. CRC, Checksums)
- corectarea erorilor (e.g. Hamming)
În acest caz, apare o decizie de proiectare în dezvoltarea protocolului. De exemplu, dacă stim ca transmisia se intampla peste un mediu cu latență mare și rată mare de corupere a datelor, cum ar fi comunicarea între Pământ și Marte (~20 de minute), ar avea sens să folosim o metodă de corectare a erorilor. În schimb, dacă latența este mică, ar fi mult mai optim să realizăm o retransmisie. Ethernet folosește un câmp de CRC pentru detectarea erorilor.
Sume de control (checksum)
Adesea, atunci când transmitem date peste un link, este necesar ca receptorul să determine dacă cadrul primit a fost corupt. Pentru a face acest lucru, transmițătorul va include un nou câmp (numit checksum) în protocol, care este rezultatul aplicării unei funcții pe conținutului cadrului. Receptorul poate recalcula această valoare cu datele din cadrul pe care acesta le-a primit și detecta cadrele corupte în cazul în care aceste două valori diferă.
Un exemplu simplu funcție de checksum este suma tuturor octetilor din cadru mod 256. Mai jos găsiți o astfel de implementare.
uint8_t compute_checksum(const char *buff, size_t count)
{
/* Ca input primim un buffer char *buf de dimensiune int count */
uint32_t sum = 0;
uint8_t checksum
/* Adăugăm în sum fiecare byte din buffer */
while (count > 0) {
sum += *((uint8_t *) buff)
buf += 1;
count -= 1;
}
checksum = sum % 256;
return checksum;
}
Ce facem dacă am detectat o erorare? De cele mai multe ori, la detecția unei erori se va face o retransmisie de către protocolul de nivel superior (e.g. TCP la nivel transport).
O problemă a algoritmilor de checksum este simplitatea acestora ce poate cauza coliziuni.
Cadru : 6 23 4
Cadru cu checksum : 6 23 4 33 (6 + 23 + 4 = 33 % mod 256 = 33)
Cadru la receptor : 8 20 5 33 (8 + 20 + 5 = 33 % mod 256 = 33)
În acest exemplu, chiar dacă conținutul mesajului s-a schimbat, checksum-ul calculat a fost același, existând o șansă de 1/256 (256 - de la operatorul de modulo) ca o eroare să nu fie detectată. Pentru a rezolva această problemă, s-a ales folosirea unor algoritmi precum Cyclic Redundancy Codes (CRC).
Note
Termenul de checksum a fost folosit inițial pentru a descrie algoritmi de tipul sume, dar în ziua de azi curpinde și algoritmi mai sofisticați precum CRC.
Endianness
În funcție de ordinea în care un șir de octeti este stocat în memorie avem două interpretări: Little Endian și Big Endian. Reprezentarea cu care suntem cel mai bine obișnuiți este Big Endian, așa cum reprezentăm datele pe foaie, cel mai semnificativ byte este primul. În imaginea de mai jos avem un exemplu de cum un int pe 32 de biți, 0x01020304 poate avea valori diferite în funcție de cum este interpretat. De notat faptul că endianness-ul este același pentru un char (1 byte)
În general, procesoarele moderne folosesc Little Endian. Totuși, plăcile de rețea, folosesc Big Endian. O să întâlnim denumirea Network Order (Big Endian) și Host Order (Little Endian).
În API-ul POSIX avem mai multe funcții care se pot folosi pentru a face trecerea Host Order <-> Network Order:
#include <arpa/inet.h>
// host to network long
uint32_t htonl(uint32_t hostlong);
uint16_t htons(uint16_t hostshort);
// network to host long
uint32_t ntohl(uint32_t netlong);
uint16_t ntohs(uint16_t netshort)
Cyclic Redundancy Codes (CRCs)
Dacă reprezentăm datele transmise ca pe un număr, atunci restul împărțirii este valoarea pe care o putem introduce în header, iar receptorul poate verifica dacă datele primite au același rest.
De exemplu, atât transmițătorul cât și receptorul sunt de acord să folosească
13131 că și împărțitor.
REST = 123131(PAYLOAD) % 13131 = 200
|DELIM|PAYLOAD|REST|DELIM|
În practică, nu folosim numere ci polinoame, printre altele fiind mult mai ușor de lucrat cu ele (nu o să avem carry). Cyclic Redundancy Codes (CRC) reprezintă restul împărțirii polinomiale modulo 2 a datelor pe care vrem să le trimitem. Putem vedea payload-ul ca și reprezentarea unui polinom.
PAYLOAD= 'H' 'i' '!'
01001000 01101001 00100001
Cu reprezentarea matematică
De ce modulo 2 (inelul claselor de resturi modulo 2)? Deoarce vrem ca indicii în urma calculelor să fie 1 sau 0, altfel, atunci când facem împărțirea, am ajunge la valori reale, iar noi, putem folosi doar valori binare.
Pentru optimizări, operațiile în acest inel sunt echivalente cu XOR (e.g. 1 + 1 = 0). În funcție de polinomul la care o să ne raportăm, avem diferite implementări de CRC. Aici gășiți mai multe exemple de polinoame si la ce sunt folosite. CRC 32 folosește următorul polinom:
cu reprezentarea in binar si hexa:
0xEDB88320 11101101-10111000_10000011_00100000
x^0
Cum avem doar 32 de biți, nu ne interesează indicele lui x^32. Polinomul a fost ales astfel încât să funcționeze bine în cazul erorilor în rafală.
Pentru string-ul "123456789", valoarea CRC32 este 0xCBF43926.
Un exemplu de imartire de polinoame modulo 2 este acesta:
10011 ) 11010110110000 = Bits of payload
=Poly 10011,,.,,....
-----,,.,,....
10011,.,,.... (operatia de xor cand primul bit e 1)
10011,.,,....
-----,.,,....
00001.,,.... (cand primul bit e zero, doar face un shift stanga
00000.,,.... pentru a lua urmatorul indice de exponent)
-----.,,....
00010,,....
00000,,....
-----,,....
00101,....
00000,....
-----,....
01011....
00000....
-----....
10110...
10011...
-----...
01010..
00000..
-----..
10100.
10011.
-----.
01110
00000
-----
1110 = Remainder = The CRC!
O posibilă implementare a algoritmului CRC32 este următoarea:
uint32_t compute_crc32(const char *buffer, size_t len)
{
/* unsigned char *buffer contine payload-ul, len este lungimea acestuia */
/* Prin conventie crc-ul initial are toti bitii setati pe 1 */
uint32_t crc = ~0; // 0xffffffff
const uint32_t POLY = 0xEDB88320;
/* Parcurgem fiecare byte din buffer */
while(len--)
{
/* crc contine restul impartirii la fiecare etapa */
/* nu ne intereseaza catul */
/* adunam urmatorii 8 bytes din buffer */
crc = crc ^ *buffer++;
for( int bit = 0; bit < 8; bit++ )
{
/* 10011 ) 11010110110000 = Bytes of payload
=Poly 10011,,.,,....
-----,,.,,....
10011,.,,.... (operatia de xor cand primul bit e 1)
10011,.,,....
-----,.,,....
00001.,,.... (asta e noua valoare a lui crc) (crc >> 1) ^ POLY
*/
if( crc & 1 )
crc = (crc >> 1) ^ POLY;
else
/* 10011 ) 11010110110000 = Bytes of payload
=Poly 10011,,.,,....
-----,,.,,....
10011,.,,....
10011,.,,....
-----,.,,....
00001.,,.... primul bit e 0,
00000.,,....
-----.,,....
00010,,.... am facut shift la dreapta, pentru ca suntem pe **little endian**
*/
crc = (crc >> 1);
}
}
// Prin conventie, o sa facem flip la toti bitii
crc = ~crc;
return crc;
}
Note
Pentru o înțelegere mai bună a matematicii din spatele CRC, vă recomandăm următorul video (40 min).
Exerciții
Pentru laboratorul acesta vom folosi scheletul oferit la adresa: lab3. Tot acolo se gaseste si un README.md cu functionalitatea disponibila. Pentru a face update la ultima varianta a sheletului, folositi
git pull.
Vom construi un protocol DataLink peste cel de data trecuta, astfel, avem deja
implementata functionalitatea de transmisie de cadre. Avem la dispozitie doua
servicii: link_recv si link_send ce se ocupa de framing si transmisia
cadrelor. Procolul nostru este encapsulat in payload-ul acelui protocol.
Vom considera un Maximum Transmission Unit (MTU) de 1500 bytes.
|--------------------|
|LEN|CHECKSUM|PAYLOAD|
|--------------------|
\ /
DELIM|PAYLOAD|DELIM -> Laboratorul trecut
1. In funcția simple_csum din schelet, implementați algoritmul
de checksum asa cum a fost descris in laborator. Vom folosi structura
protocolului definita in common.h. Receptorul va afisa Frame corrupted
atunci cand detecteaza un cadru corupt. Vom folosi functia htonl pentru a trece valoarea pe care vrem o sa o scriem
in campul sum in Network Order. Invers, la receiver, vom folosi ntohl pentru
a trece in Host Order
Note
In scriptul run_experiment.sh avem campul CORRUPTION care manipuleaza rata
de corupere a pachetelor.
2. Vom modifica implementarea precedenta pentru a folosi CRC 32 in loc de checksum.
3. Vom modifica protocolul pentru a realiza o retransmisie simpla. Transmitatorul va trimite un cadru si va astepta un raspuns de la receptor. Receptorul daca detecteaza un cadru corupt, va trimite un cadru prin care il va informa pe transmitator de acest lucru (NACK), altfel, va trimite o confirmare (ACK). Este la latitudinea voastra de cum veti codifica cele doua tipuri de raspunsuri. Vom testa implementarea prin trimiterea unui fisier in chunk-uri ce nu depasesc dimensiunea maxima impusa de MTU.
Pe link-ul nostru se strica doar 1 bit / cadru, putem astfel face codificaerea ACK/ NACK in asa fel incat daca este modificata, sa putem diferentia intre cele 2 tipuri de mesaje.
Lectură laborator
De parcurs înainte de laborator:
Routing
O să considerăm acum următorul scenariu. În marile capitale ale Europei avem mai multe dispozitive. De exemplu, în Londra, avem 4 dispozitive conectate prin Ethernet (3 calculatoare și un dispozitiv pe care îl vom numi router). La fel și în București. Dispozitivele numite "router" sunt conectate printr-un protocol de level 2 de tip Point-to-Point.
Vrem să trimitem un cadru de la Host A, în Londra, la
Host B, în București. Dacă Host A, trimite un cadru de nivel 2, în acest
caz Ethernet cu adresa destinație MAC B, acesta nu ar fi considerat de
niciunul dintre dispozitive pentru că nimeni din Londra nu are aceastra adresa
MAC. Dacă în schimb, am modifică aceste dispozitive numite routere să știe unde
se află fiecare adresa MAC din toată europa, cel din Londra ar primi un cadru Ethernet
de la Host A cu destinația MAC B și ar trimite conținutul acestuia către Paris
folosind protocolul de tip PPP dintre acestea. Totuși, între Londra și Paris
este o conexiune de tip PPP, destinația se pierde între aceste conexiuni
deoarce protocoalele de tip PPP nu folosesc o destinație.
Avem nevoie de un protocol peste nivelul DataLink care să se ocupe cu identificarea și transmisia între ceea ce vom numi de acum rețele (e.g. rețeaua din București). În acest scop, a fost dezvoltat protocolul IP Protocol (IP) de nivel network. Astfel, o datagramă IP va fi encapsulata atât în protocolul Ethernet cât și în PPP, și routerele se vor ocupa de transmisie.
Protocoale utilizate
În cadrul laboratorului vom lucra cu pachete reale din internet. Astfel, vor fi folosite următoarele protocoale discutate la curs: Ethernet și IP.
Ethernet
Ethernet este echivalentul protocolului de DataLink pe care l-am implementat în primele laboratoare. Noi vom lucra doar cu cadre Ethernet ce sunt transmise ca payload peste implementarea protocolului de nivel fizic Ethernet. Cum CRC-ul este calculat în hardware, nu o să îl regăsim în header. În acest caz, header-ul pe care îl vom folosi este următorul:
Ethernet Frame
+-----------------+------------+-------------+
| Bytes 0-5 | Bytes 6-11 | Bytes 12-13 |
+------------------------------+-------------+
| Destination MAC | Source MAC | EtherType |
+-----------------+------------+-------------+
Adresa MAC Destinație reprezintă identificatorul dispozitivului de nivel 2 către care a fost trimis acest cadru.
În cadrul laboratorului puteți folosi următoarea structura pentru un
cadru Ethernet. Pentru câmpul EtherType ne interesează doar valoarea
ETHERTYPE_IP (0x0800).
struct ether_header {
uint8_t ether_dhost[6];
uint8_t ether_shost[6];
uint16_t ether_type; // ETHERTYPE_IP
};
IPv4
Protocolul IP este utilizat pentru a permite dispozitivelor conectate în rețele diferite să schimbe informații prin intermediul unui dispozitiv intermediar numit router. Header-ul unui pachet (packet) IP este următorul:
+----+---------------+---------------+---------------+---------------+
|Word| 1 | 2 | 3 | 4 |
+----+---------------+---------------+---------------+---------------+
|Byte|0|1|2|3|4|5|6|7|8|9|0|1|2|3|4|5|6|7|8|9|0|1|2|3|4|5|6|7|8|9|0|1|
+----+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| 0|Version| IHL |Type of Service| Total Length |
+----+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| 4| Identification |Flags| Fragment Offset |
+----+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| 8| Time to Live | Protocol | Header Checksum |
+----+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| 12| Source Address |
+----+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| 16| Destination Address |
+----+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
De notat faptul că astăzi nu vom atinge decât câmpurile Time to Live,
checksum și adresa IP destinație. Câmpul Time to Live este un număr
decrementat de fiecare router pentru a evita buclele. checksum este câmpul
folosit pentru a verifică integritatea header-ului IP. Destionation Address
este adresa IP a destinației.
Următoarea strucura poate fi folosită pentru a reprezenta un pachet IPV4.
struct iphdr {
// The following syntax means that version has 4 bits and ihl 4 bits.
uint8_t ihl:4, version:4; // don't care
uint8_t tos; // don't care
uint16_t tot_len; // don't care
uint16_t id; // don't care
uint16_t frag_off; // don't care
uint8_t ttl; // Time to Live -> to avoid loops, we will decrement
uint8_t protocol; // don't care
uint16_t check; // checksum -> Since we modify TTL,
// we need to recompute the checksum
uint32_t saddr; // don't care
uint32_t daddr; // the destination of the packet
};
Observăm că o adresa IP precum 10.30.4.2 poate fi reprezentată în memorie ca
un integer pe 32 de biți, uint32_t.
Câmpul checksum este complementul față de 1 al sumei tuturor cuvintelor de 16
biți din header. Totuși, cum noi modificăm doar câmpul TTL și pentru că checksum
este o sumă, există o metodă mai rapidă de a calcula noul checksum folosind
formulă:
Fie:
HC - vechiul checksum din header
C - complementul față de 1 al sumei campurilor din header
HC' - noul checksum
m - vechea valoare a câmpului de 16 biți (TTL în cazul nostru)
m' - moua valoare a câmpului de 16 biți (TTl --)
HC' = ~(C + (-m) + m') = HC + (m - m') = HC + m + ~m'
complement față
de 1 al noului
mesaj
Cum header-ul IP o sa aiba mereu un camp diferit de zero, iar checksumul este complementul sumei, valoarea checksumului nu o sa ajunga niciodata -0 (0xFFFF). Astfel, formula precedenta poate ajunge sa returneze -0 cand ar trebui sa fie +0 (0x0000). Pentru a rezolva aceasta problema vom face urmatoarea modificare:
HC' = ~(C + (-m) + m')
= ~(~HC + ~m + m')
Avem astfel formula finala:
new_checksum = ~(~old_check + ~((uint16_t)old_ttl) + (uint16_t)ip_hdr->ttl) - 1;
Acel -1 de la final apare pentru a evita translatia din network order in host order pentru valorile de ttl pe 16 biti.
Adrese IP
În general, o adresă IP este de forma 10.20.30.40 și este reprezentată pe 32 de biți.
Cum avem foarte multe adrese IP, în general o să le structurăm în blocuri (rețele). O
rețea este identificată printr-un prefix și o mască. De exemplu,
rețeaua din București în exemplul nostru este 10.20.30.0/24.
Network în București
Network: 10.20.30.0/24
Prefix: 10.20.30.0
Mask: 255.255.255.0 (24 = nr de biți de 1 de la stânga la dreapta)
Câte adrese IP sunt în rețeaua din București? Avem 255 de adrese IP disponibile,
10.20.30.0 - 10.20.30.255. Adresele din acest bloc pot fi asignate
dispozitivelor din București.
Procesul de forward (dirijiare)
Un router, pentru a trimite un pachet către următorul dispozitiv (hop) va trebui să realizeze mai multe acțiuni (proces de forward). Procesul complet de forwarding este următorul:
- Pe una dintre interfețe este recepționat un pachet IP.
- Verifică checksumul. Dacă acesta este greșit, aruncă pachetul
- Rulează algoritmul de Longest Prefix Match (LPM) în tabela de rutare pentru a găsi următorul hop.
- În cazul în care nicio intrare din tabela nu face match, routerul aruncă pachetul.
- Routerul decrementează câmpul TTL din header-ul IP. În cazul în care TTL este 0, pachetul este aruncat.
- Recalcuelaza checksum-ul folosind formula incrementală descrisă anterior.
- Routerul face update la adresa MAC sursă a pachetului în adresa proprie înainte de a îl trimite la următorul HOP și adresa MAC destinație a următorului HOP.
- Pachetul este trimis către următorul hop identificat prin LPM
Următorul slideshow prezintă acest proces:
Tabela de rutare este populată de algoritmii de routare (nu o să lucrăm cu ei la PCom) și este structurată astfel:
Prefix Next hop Mask Interface
192.168.0.0 192.168.0.2 255.255.255.0 0
192.168.1.0 192.168.1.2 255.255.255.0 1
192.168.2.0 192.168.2.2 255.255.255.0 2
192.168.3.0 192.168.3.2 255.255.255.0 3
Un dispozitiv are mai multe interfețe pe care poate trimite pachete (e.g. din Londra, are una pentru Paris și una pentru Berlin).
Longest Prefix Match
Pentru a determina prefixul dintr-o adresă IP și o mască, putem folosi
următoarea operație pe biți: ip & mask. De exemplu:
Adresa IP Mask Prefix
10.20.30.4 & 255.255.255.0 = 10.20.30.0
Algoritmul are o specificație relativ simplă:
- Routerul caută în tabela de rutare intrările care fac match pe adresa IP
destinație din pachetul IPv4.
(ip & mask) == prefix - Dintre toate rutele pe care s-a făcut match în etapă anterioară, este aleasă ruta cea mai specifică (adică ruta cu prefixul cel mai mare). Dacă două rute au aceeași specificitate, se va folosi ruta cu cel mai mic metric.
Un exemplu este cel din următoarea imagine, în care ruta a doua este cea mai specifică și următorul hop este conectat pe interfața S1.
Ruta 1 nu face match deoarece în acest caz (destIp & mask) != prefix. În schimb ruta 2 și 3 fac match. Totuși, după cum vedem și în imagine, ruta 2 este mai specifică.
O posibilă implementare în O(n) a algoritmului este următoarea:
// Avem o tabela de rutare table {prefix, next_hop, mask, interface}
// tabela trebuie sortata descrescator prefix si masca
// qsort((void *)table, table_len, sizeof(struct route_table_entry), comparator);
for (int i = 0; i < table_len; i++) {
/* Cum tabela este sortată, primul match este prefixul ce mai specific */
if (table[i].prefix == (target_ip & mask)) {
return &table[i];
}
}
Setup
Pentru a simula o rețea virtuală vom folosi Mininet. Mininet este un simulator de rețele ce folosește în simulare implementări reale de kernel, switch și cod de aplicații.
sudo apt update
sudo apt install mininet openvswitch-testcontroller tshark python3-click python3-scapy xterm python3-pip
sudo pip3 install mininet
După ce am instalat Mininet, vom folosi următoarea comandă pentru a crește dimensiunea fontului în terminalele pe care le vom deschide.
echo "xterm*font: *-fixed-*-*-*-18-*" >> ~/.Xresources
xrdb -merge ~/.Xresources
Când o să rulăm simularea, e posibil să întâlniți următoarea eroare:
Exception: Please shut down the controller which is running on port 6653:.
Pentru a rezolva problema, va trebui să rulați pkill ovs-test.
NOTE
- Pe unele versiuni mai vechi de Ubuntu este posibil să
fie nevoie să instalați
python-clickșipython-scapyfiind folosit Python 2. - Ar trebui sa mearga ok si pe WSL 2
Mininet
Mininet folosește python pentru a specifica o topologie. În următorul exemplu prezentăm o topologie formată din 3 dispozitive, dintre care unul acționează ca un router.
from mininet.topo import Topo
from mininet.cli import CLI
from mininet.net import Mininet
class MyTopo(Topo):
"O topologie este definită ca o clasa ce moștenește Topo"
def build(self):
# Adăugăm dispozitivele din topologie: hosts și un switch de L2
leftHost = self.addHost('host2')
rightHost = self.addHost('host1')
# Pe acest dispozitiv, teoretic ar trebui pornit un program ce
# implementează protocolul de rutare
router = self.addHost('router')
# Adăugăm link-urile
self.addLink(leftHost, router)
self.addLink(rightHost, router)
# Instantiem topologia
topo = MyTopo()
# Pornim simularea
net = Mininet(topo)
net.start()
# Pornim terminalul de control al topologiei
CLI(net)
net.stop()
Vom folosi python pentru a rula topologia, presupunem că topologia se află în fișierul topo.py.
❯ sudo python3 topo.py
mininet>
Din terminalul principal vom putea deschide terminale pe oricare dintre dispozitive:
# pornește un terminal pe host1
mininet> host1 xterm&
Puteti folosi orice terminal, nu doar xterm (E.g. gnome-terminal).
Comanda precedentă va deschide un terminal, din acel terminal vom putea executa orice
binar de linux care se află și pe mașină noastră, precum Wireshark.
sudo wireshark&
Sau pentru depanare ping:
❯ ping router_ip
PING router_ip (router_ip) 56(84) bytes of dată.
64 bytes from host_ip: icmp_seq=1 ttl=58 time=21.2 ms
64 bytes from host_ip: icmp_seq=2 ttl=58 time=21.5 ms
64 bytes from host_ip: icmp_seq=3 ttl=58 time=20.6 ms
Din terminalul unui dispozitiv putem rula ip address show pentru a vedea toate interfețele disponibile și adresele IP de pe acestea.
Exerciții
În laboratorul de astăzi vom implementa procesul de forwarding al unui router. Topologia este una simplă, avem 4 dispozitive (hosts) conectate la un router
Scheletul laboratorului se regăsește la adresa de aici. Funcționalitatea disponibilă este descrisă în README.md
Vom folosi comanda ping pentru a verifica conectivitatea între hosts. Pentru
depanare vom folosi Wireshark.
Pe router va fi rulat manual programul rezultat în urma rulării comenzii make. În cadrul laboratorului vom lucra în fișierul router.c.
1. Vom deschide o instanță
de Wireshark pe router. Din oricare dintre hoști, trimiteți un ping către un
alt host. Din Wireshark studiați pachetul primit. Ce protocoale sunt utilizate
și care este relația dintre ele?
Este util de știut faptul că
Wireshark poate ascultă în același timp pe mai multe interfețe (avem 4 pe
router).
Hostii au următoarele adrese alocate:
Host IPv4 MAC
host0 192.168.0.2 de:ad:be:ef:00:00
host1 192.168.1.2 de:ad:be:ef:00:01
host2 192.168.2.2 de:ad:be:ef:00:02
host3 192.168.3.2 de:ad:be:ef:00:03
2. Implementați procesul de forward pentru IPv4. Tabela de rutare este
reprezentată printr-un array și poate fi accesată la un index prin
variabila rtable[i]. rtable_size este dimensiunea tabelei. Nu trebuie
implementată parsarea fișierului rtable.txt. Structura de date
utilizate pentru o intrare din tabelă este route_table_entry din
lib.h.
2.1. Verificați integritatea header-ului IP folosind câmpul de checksum din header. Dacă acesta nu este bun, vom da drop la pachet.
2.2. Folosind algoritmul de LPM, determinați următorul hop și interfața pe care va trebui să trimitem pachetul.
Continutul tabelei de rutare este in network order. In cazul unei comparatii, vom trece valorile in host order (e.g. ntohl(mas1) > ntohl(mask2)).
2.3. Pentru fiecare pachet scădeți TTL-ul sau Hop Limit-ul; dacă TTL-ul este pozitiv, recalculați checksum-ul.
2.4. Actualizați header-ul Ethernet al pachetului. În funcție de adresa IP a
destinației, vom căuta în tabela MAC adresa MAC a următorului hop. Tabela
este statică și putem interacționa cu ea folosind funcțiile din lib.h.
Pentru a putea trimite pachetul IP mai departe, trebuie să completăm adresa MAC a hostului următor. În mode normal, tabela MAC este populată folosind protocolul ARP, în cadrul acestui laborator vom considera o tabelă statică deja populată folosind ARP
Note
Puteți folosi inet_pton pentru a parsa adresele IPv4. Pentru a parsa MAC-ul în binar
puteți folosi funcția hwaddr_aton pusă la dispoziție în lib.h.
Funcția int get_interface_mac(int interface, uint8_t *mac) întoarce adresa MAC
a interfeței router-ului.
Lectură laborator
De parcurs înainte de laborator:
Nivelul Transport
Protocoalele de nivel transport folosesc servicile oferite de către nivelul rețea. În internet, nivelul rețea ofera un serviciu fără conexiune. Nivelul rețea identifica fiecare host folosind o adresa IP. Nivelul retea poate transmite pachete ce au până la 65KBytes de date către orice desținație cunoscută din rețeaua locală sau din Internet.
Nivelul rețea nu garantează transmiterea datelor, nu poate detecta erori de transmisie a datelor și nu păstrează ordinea de transmisie. Toate aceste lipsuri sunt rezolvate de către protocoalele de nivel transport.
În general, implementarea protocoalelor de nivel transport se face în sistemul de operare.
Porturi
Porturile sunt conceptul ce ne ajută să facem multiplexare între aplicații.
În contextul rețelelor de comunicație, un port este un număr asociat unui socket dintr-un proces (nu unui host). Dacă un proces dorește să comunice cu alte procese, aceasta expune un port, o locație logică prin care accepta conexiuni sau prin care se realizează schimbul de date.
Aceste numere permit aplicațiilor să partajeze concurent resursele de rețea. Serverul de mail, de exemplu, nu așteapta terminarea altor procese ce implica reteaua (ex. web surfing) pentru a putea trimite un mail la destinație.
În antetul protocoalelor de nivel transport, portul este
reprezentat pe 2 bytes: uint16_t port;.
Mai multe porturi au fost rezervate în procesul de standardizare. Astfel, în RFC 1340 gasiți o listă de porturi care sunt considerate ca fiind rezervate (sau well-known) pentru anumite protocoale. De exemplu, portul 21 este rezervat pentru
File Transfer Protocol(FTP).
UDP
Serviciu neorientat conexiune: nu se stabilește o conexiune între client și server. Așadar, serverul nu va așteapta apeluri de conexiune, ci așteaptă direct datagrame de la clienti. Acest tip de comunicare este întâlnit în sistemele client-server în care se transmit puține mesaje și în general prea rar pentru a menține o conexiune activă între cele două entități.
Nu se garantează ordinea primirii mesajelor și nici corectarea pierderilor pachetelor. UDP-ul se utilizeaza mai ales în rețelele în care există o pierdere foarte mică de pachete și în cadrul aplicatiilor pentru care pierderea unui pachet nu este foarte importantă (Un exemplu: aplicațiile streaming video).
Are un overhead foarte mic, în comparație cu celelalte protocoale de transport (Are un header de 8 bytes, în comparație cu TCP-ul care are minim 20 bytes)
Header UDP
Header-ul UDP are 8 bytes si are urmatoarea structura:
0 7 8 15 16 23 24 31
+--------+--------+--------+--------+
| Source | Destination |
| Port | Port |
+--------+--------+--------+--------+
| Length | Checksum |
+--------+--------+--------+--------+
Portul sursa este ales random de către mașina sursa a pachetului dintre porturile libere existente pe acea mașina. Este un număr pe 16 biti, între 0 si 65535. Identifică procesul UDP care a transmis datagrama.
Portul destinatie este portul pe care mașina destinatie poate recepționa pachete. Identifică socket-ul UDP care va procesa datele primite.
Length este lungimea în octeti (bytes) a datagramei (header size + data size).
Checksum este valoarea sumei de verificare pentru datagrama.
Putem folosi următoarea structură pentru a reprezenta header-ul UDP:
struct udphdr
{
uint16_t sport; /* source port */
uint16_t dport; /* destination port */
uint16_t ulen; /* udp length */
uint16_t sum; /* udp checksum */
};
Sockets
În cadrul laboratorului nu vom implementa protocolul UDP, ci vom folosi implementarea existentă din Kernel-ul de Linux. Acest lucru se realizează prin intermediul API-ului de sockets. Network stack-ul din Linux se ocupă de parsarea și interactiunea cu datagramele UDP, nouă returnând-se doar conținutul datagramei.
Un socket este un canal generalizat de comunicare între procese, reprezentat în Linux/UNIX print-un descriptor de fișiere. El ofera posibilitatea de comunicare între procese aflate pe masini diferite într-o retea.
API-ul de sockets poate fi folosit și pentru IPC (Inter-Process Communication) între procese ce rulează pe aceeași mașină, prin specificarea adresei de loopback sau a unei interfețe existente pe mașină.
Comunicare client-server UNIX
Intr-o arhitectură client-server, clientul trimite request-uri (cere resurse) către server, iar acesta din urmă trimite înapoi un raspuns (cu resursa).
Pasi urmați pentru a schimba mesaje folosind UDP la nivelul Transport folosind API-ul de sockets sunt următorii:
- Deschide un socket unix în scopul de a permite comunicarea între procese/statii diferite folosind descriptori de fisiere (file descriptors) cu apelul socket().
- Asociaza o adresa pentru socketul deschis cu apelul bind(). În general, folosim bind() atunci când dorim să așteptam datagrame pe un anumit port. Bind este chemat pe server pentru a specifica la ce port să lege socket-ul.
- Trimite/Recepționeaza date cu apelul recvfrom()/sendto().
- Închide socket prin close().
+--------+ +--------+
| Server | | Client |
+--------+ +--------+
| |
| | Descriere:
socket() socket() socket() - creează un endpoint de comunicare
| | bind() - atașează o adresa unui socket
bind() | sendto() - trimite o datagrama
| | receive() - primește o datagramă
recv_from() <-------- sendto() close() - eliberează file descriptorul
| |
send_to() --------> recvfrom()
| |
close() close()
O implementare simplă de client care trimite o datagramă:
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <netinet/in.h>
#define PORT 50000
#define MAXLINE 1024
int main() {
int sockfd;
char buffer[MAXLINE];
char *hello = "Hello from client";
struct sockaddr_in servaddr;
// Creating socket file descriptor
if ( (sockfd = socket(AF_INET, SOCK_DGRAM, 0)) < 0 ) {
perror("socket creation failed");
exit(EXIT_FAILURE);
}
memset(&servaddr, 0, sizeof(servaddr));
// Filling server information
servaddr.sin_family = AF_INET;
servaddr.sin_port = htons(PORT);
servaddr.sin_addr.s_addr = INADDR_ANY;
int n, len;
sendto(sockfd, (const char *)hello, strlen(hello),
0, (const struct sockaddr *) &servaddr,
sizeof(servaddr));
printf("Hello message sent.\n");
n = recvfrom(sockfd, (char *)buffer, MAXLINE,
MSG_WAITALL, (struct sockaddr *) &servaddr,
(socklen_t *)&len);
buffer[n] = '\0';
printf("Server : %s\n", buffer);
close(sockfd);
return 0;
}
Implementarea server-ului care primește această datagramă este următoarea:
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <netinet/in.h>
#include <sys/uio.h>
#include <unistd.h>
#define PORT 50000
#define MAXLINE 1024
int main() {
int sockfd;
char buffer[MAXLINE];
char *hello = "Hello from server";
struct sockaddr_in servaddr, cliaddr;
// Creating socket file descriptor
if ( (sockfd = socket(AF_INET, SOCK_DGRAM, 0)) < 0 ) {
perror("socket creation failed");
exit(EXIT_FAILURE);
}
memset(&servaddr, 0, sizeof(servaddr));
memset(&cliaddr, 0, sizeof(cliaddr));
// Filling server information
servaddr.sin_family = AF_INET; // IPv4
servaddr.sin_addr.s_addr = INADDR_ANY; // INADDR_ANY = 0.0.0.0
servaddr.sin_port = htons(PORT);
// Bind the socket with the server address
if ( bind(sockfd, (const struct sockaddr *)&servaddr,
sizeof(servaddr)) < 0 ) {
perror("bind failed");
exit(EXIT_FAILURE);
}
int n;
// len is an unsinged int value/result, but it is recomanded
// to use the socklen_t type
socklen_t len = sizeof(cliaddr);
n = recvfrom(sockfd, (char *)buffer, MAXLINE,
MSG_WAITALL, (struct sockaddr *) &cliaddr,
&len);
buffer[n] = '\0';
printf("Client : %s\n", buffer);
sendto(sockfd, (const char *)hello, strlen(hello),
0, (const struct sockaddr *) &cliaddr,
len);
printf("Hello message sent.\n");
return 0;
}
socket()
#include <sys/types.h>
#include <sys/socket.h>
/* creare socket in C */
/* int socket(int domain, int type, int protocol); */
/* pentru UDP, folosim un socket de tip SOCK_DGRAM */
int sockid = socket(PF_INET, SOCK_DGRAM, 0);
if (sockid == -1) {
/* trateaza eroare */
}
Explicatii:
sockid- file descriptor pentru socket. În caz de eroare se întoarce -1 si se seteaza variabilaerrno.domain- reprezintă familia de protocoale pe care urmează să le utilizam în transferul informației. Vom folosi valorile PF_INET pentru IPv4 sau PF_INET6 pentru IPv6.type- reprezinta tipul socketului. Valori uzuale:- SOCK_STREAM - Indicata stabilirea unei comunicatii bazata pe construirea unei conexiuni intre sursa si destinatie. Comunicatia este FIFO, fiabila si sigura, o vom folosi la laboratorul urmator cu TCP.
- SOCK_DGRAM - Ofera un flux de date bidirectional, care nu promite sa fie sigur, in secventa sau neduplicat. Un proces care receptioneaza mesaje pe un socket datagrama, poate gasi mesaje duplicate si posibil intr-o ordine diferita fata de cea in care au fost trimise.
- protocol - specifica protocolul de transport utilizat. Vom seta pe valoarea 0, pentru a se alege protocolul corect in functie de type.
Pentru a afla mai multe informatii, putem accesa urmatorul capitol5.2 socket()—Get the File Descriptor!
bind()
Utilizată în server pentru a lega un socket de un port și eventual o anumită adresă. Practic, bind este folosit pentru a indica implementării de networking din Kernel să lege acel socket la un anumit port și (opțional) la o anumită adresă IP. Astfel, stiva va trimite către acel socket doar datagramele ce au ca port destinație portul ales.
#include <sys/types.h>
#include <sys/socket.h>
/*int bind(int sockfd, struct sockaddr *my_addr, int addrlen)*/
struct sockaddr myaddr;
memset(&myaddr, 0, sizeof(servaddr));
myaddr.sin_family = AF_INET; // IPv4
/* INADDR_ANY = 0.0.0.0 as uint32 */
myaddr.sin_addr.s_addr = INADDR_ANY;
myaddr.sin_port = htons(atoi(8888));
int rs = bind(sockfd, myaddr, sizeof(servaddr);
/* in urma apelului, sockfd va avea adresa my_addr */
if (rs == -1) {
/* trateaza eroare* /
}
Explicații:
sockfd- Descriptorul de fișier returnat desocket();my_addr- Structura sockaddr ce conține informații despre adresa IP și port;addrlen- lungimea structurii ce stochează adresa. (a luimy_addr).
Pentru a afla mai multe informatii, putem accesa 5.3 bind()—What port am I on?.
recvfrom()/ sendto()
Funcțile sunt folosite pentru a primi/trimite o datagrama peste un socket. Mai multe detalii găsiți aici.
#include <sys/types.h>
#include <sys/socket.h>
struct sockaddr to;
// Filling server information
memset(&to, 0, sizeof(servaddr));
to.sin_family = AF_INET;
to.sin_port = htons(8888);
int rc = inet_aton("127.0.0.1", &to.sin_addr);
int byteswrite = sendto(int sockfd, char *buff, int nbytes, int flags, struct sockaddr *to, int addrlen);
if (byteswrite == -1) {
/* trateaza eroare */
}
/* from va fi populata de apelul recvfrom si va contine informatii despre cine a trimis datagrama catre noi */
struct sockaddr from;
int bytesread = recvfrom(int sockfd, char *buff, int nbytes, int flags, struct sockaddr *from, int *addrlen);
if (bytesread == -1) {
/* trateaza eroare */
}
Explicatii:
sockfd- Descriptorul de fisier returnat de socket()buff- Bufferul unde se găsesc datele ce urmează a fi trimise/bufferul unde se vor recepționa datele;flags- Precizează condiții de efectuare a transmisiei;to/from- Structura ce indica adresa unde se trimite/de unde se primesc date. In cazul lui recvfrom() se populeaza de către funcție.addrlen- Lungimea structurii to/from în octeți. În cazul recvfrom(), este un pointer care trebuie să indice, la început, către o valoare egală cu dimensiunea zonei de memorie alocate pentru structura de tip adresă și este completă (restrânsă) - dacă este necesar - de către funcție.
close()/ shutdown()
Pentru a închide un socket se foloseste funcția de închidere a unui descriptor de fisier din Unix:
#include <unistd.h>
int close(ind fd);
Acest lucru va împiedica atât realizarea de alte citiri, cât și de scrieri din socket. Pentru mai mult control asupra socketului, se folosește funcția shutdown(), care permite întreruperea comunicatiei selectiv, schimband modul de utilizare a legaturii full-duplex.
Pentru a afla mai multe informatii, putem accesa 5.9 close() and shutdown()—Get outta my face!.
#include <sys/socket.h>
int shutdown(int sockfd, int how);
Explicatii:
sockfd- Descriptorul de fișier returnat de socket()how- Specifica modul de inchidere:SHUT_RD- Nu se mai citesc date.SHUT_WD- Nu se mai pot face transmiteri de date.SHUT_RDWR- Se intrerupe comunicația în ambele direcții.
Note
Shutdown() nu închide un descriptor de fisier, ci doar îi schimbă modul de utilizare. Resursele trebuie eliberate folosind close().
Beyond C/C++
API-ul de sockets este o o abstractizare foarte răspândită. Practic majoritatea interacțiunilor cu Internetul o să fie prin intermediul API-ul de sockets din sistemul de operare. Spre exemplu, orice implică networking în Python funcționează peste o abstractizare de sockets din limbaj care cheamă apelurile de sistem de sockets. În codul de mai jos putem vedea un astfel de exemplu.
import socket
localIP = "127.0.0.1"
localPort = 20001
bufferSize = 1024
msgFromServer = "Hello UDP Client"
bytesToSend = str.encode(msgFromServer)
# Create a datagram socket
UDPServerSocket = socket.socket(family=socket.AF_INET, type=socket.SOCK_DGRAM)
# Bind to address and ip
UDPServerSocket.bind((localIP, localPort))
print("UDP server up and listening")
# Listen for incoming datagrams
while(True):
bytesAddressPair = UDPServerSocket.recvfrom(bufferSize)
message = bytesAddressPair[0]
address = bytesAddressPair[1]
clientMsg = "Message from Client:{}".format(message)
clientIP = "Client IP Address:{}".format(address)
print(clientMsg)
print(clientIP)
# Sending a reply to client
UDPServerSocket.sendto(bytesToSend, address)
Stop-And-Wait
Un protocol foarte simplu pe care îl putem dezvolta peste protocolul UDP se numește Stop-and-Wait. În imaginea de mai jos avem o reprezentare grafică a acestui protocol. Presupunem că nu exista pierderi pe link-urile dintre host și receiver.
Transmitatorul, trimite o datagrama UDP, așteaptă confirmarea de la receptor, iar apoi trimite următoarea datagrama UDP. ACK-ul este tot o datagrama, doar ca aceasta nu cară date, ci doar confirmă primirea datagramei anterioare.
Protocolul nostru simplu, are totuși o problemă: nu foloseste link-urile optim. Dacă noi am avea un link de 100Mbps cu un delay de 100ms între Sender și Receiver, atunci protocolul în forma actuală ar avea un throughput de sub 3% din bandă deoarece o datagrama UDP poate avea cel mult 65507 bytes (atunci când folosim IPv4). Pentru a rezolva aceasta problemă, a fost dezvoltată tehnica de fereastră glisantă.
Fereastra Glisanta (Sliding Window)
Pentru a folosi un link într-un mod optim, vom folosi tehnica de fereastră
glisantă (sliding window). Vom trimite window_size datagrame fără să
așteptăm după un ACK, apoi pentru fiecare ACK primit, vom face slide
fereastrei la dreapta.
Următoarea simulare prezintă un schimb de datagrame pentru o fereastra de 5.
Pentru a porni simularea apăsați Start Simulation.
Dimensiunea ferestrei
Vom presupune un caz simplu în care 2 gazde pot comunica datagrame UDP peste mai multe link-uri:
L1 L2 L3
Host A <------> Router <--------> Switch <-------> Host B
L1, L2, L3 - 10 MBps, 5ms latenta, 0% pierderi de pachete
Cum calculam dimensiunea ferestrei? Cum toate link-urile au aceiasi parametrii, vom face calculul o singură dată. Primul pas este determinarea valorii BDP-ului (Bandwidth Delay Product):
În cazul în care datagramele pe care le trimitem au cel mult 1500 bytes, atunci
pentru a folosi link-ul într-un mod optim, dimensiunea ferestrei este următoarea:
Am presupus ca dimensiunea maxima de
1500 bytesinclude și antelele protocoalelor de nivel inferior precum IP și Ethernet.
Exercitii
Scheletul laboratorului se regăsește la adresa de aici. Verificati sa aveti ultima versiune de schelet. Funcționalitatea disponibilă este descrisă în README.md. În continuare vom folosi Mininet pentru a simula topologia simpla:
sudo python3 topo.py.
1. Scheletul de cod are o implementare simplă de client-server peste UDP
folosind API-ul de sockets. Vom deschide pe router o instanță de
Wireshark pentru a studia datagrama trimisa de sender. Cum arată
datagramele trimise de către sender? Care este portul sursa?
Pe
h1vom rula clientul, iar peh2serverul. Routerul rulează per0.
2 Plecând de la scheletul de cod implementați protocolul stop-and-wait pentru a trimite conținutul unui fisier de la sender la receiver. Fisierul va fi trimis în bucati de maxim 1024 bytes. La final, vom măsura timpul de rulare al serverului. Topologia din Mininet pe care o vom folosi este următoarea:
L1 L2
client (h1) <--> router (r1) <--> server (h2)
Link 1 - 10 Mbps, 5ms delay, 0% packet loss (linia 57 din topo.py)
Link 2 - 10 Mbps, 5ms delay, 0% packet loss (linia 60 din topo.py)
h1 IP: 192.168.1.100
h2 IP: 172.16.0.100
Note
După ce am testat manual ca implementarea este corectă, vom folosi sudo python3 topo.py benchmark
pentru a măsura performanța implementării noastre. De exemplu, dat fiind că avem un delay de
5 ms, observam un runtime total al serverului de 30 ms, din care 10 ms cât a stat blocat
în recv() ca să ajungă datagramele la el și alte 20 ms din alte surse (e.g. overhead de la OS). Exemplu de output:
##### Benchmark results #####
[Server] Received: Hello world!
Total time = 0.030401 seconds
#############################
3 Pentru a folosi link-ul intr-un mod optim, vom modifica implementarea
de stop-and-wait sa folosească o fereastră glisantă de dimensiune optimă.
Astfel, vom calcula BDP-ul pentru fiecare link si vom alege minimul dintre ele, pentru a evita congestionarea ambelor segmente.
Dimensiunea (în datagrame a) ferestrei optime este [BDP / datagram_size]. Cu ce procent este mai rapidă aceasta implementare?
4 Ne vom conecta toți în aceași rețea (e.g. pe acelasi WiFi) pentru
a trimite fișiere către alți colegi din sala. Nu vom mai rula peste Mininet.
Ne vom grupa câte doi pentru a trimite un fișier de la unul la celalalt.
Mai exact, unul dintre voi va porni un server cu bind pe INADDR_ANY
(0.0.0.0), iar clientul va putea trimite catre ip-ul celuilalt fisierul.
Note
Dacă folosiți mașini virtuale și nu aveți rețeaua pe modul Bridge, va
trebui să faceți port forwarding din setarile hypervisorului. Pentru
a verifica dacă sunteți în aceeasi retea, folositi ip address show și confirmați că adresa este din același subnet cu cel al gazdei.
Lectură laborator
De parcurs înainte de laborator:
Retransmisie peste UDP
In laboratorul precedent am dezvoltat un protocol simplu cu fereastra glisanta peste un link ideal. Totusi, in realitate, link-urile au pierderi. Astazi, vom dezvolta un alt protocol peste UDP cu retransmisie. Acesta va asigura transferul corect de date intre un server si un client peste un link care pierde date.
In acesta laborator, unitatea de transmisie pe care o vom folosim este segmentul.
Go Back N ARQ
O tehnică dezvoltată pentru a realiza retransmisia este Go-Back-N ARQ. Este un caz special de fereastră glisantă, în care transmitătorul are o fereastră N și receptorul 1. La receptor, orice segment care nu este așteptat este aruncat. Transmitătorul retransmite toate segmentele din fereastră la declanșarea unui timer.
Simularea urmatoare prezinta surprinde acest comportament. Pentru a pierde click pe un segment, apasati click pe acesta.
Pseudocodul aferent implementarii este urmatorul. Consideram ca segmentele se pierd, coruperea fiind detectata de nivelele inferioare si pachete fiind aruncate.
N = window size
Rn = request number
Sn = sequence number
Sb = sequence base
Sm = sequence max
void receiver() {
Rn = 0;
while (1) {
if the segment received = Rn then
Accept the segment and send the payload to upper lever
Rn := Rn + 1
else
Drop packet
Send ACK with Sequence Number Rn
}
}
void sender() {
Sb := 0
Sm := N + 1
Transmit Sm segments
while (1) {
if you receive an ACK with Sequence number Rn > Sb then
Transmit segments from Sm to Sm + Rn - Sb
Sm := (Sm − Sb) + Rn
Sb := Rn
if Timeout Expired
Transmis Sm backets from Sb
}
}
Implementarea ferestrei glisante
Fereastra glisantă poate fi implementată sub forma unei liste. Lista va conține
toate segmentele. Vom folosi min_seq si max_seq pentru a determina pozitia
ferestrei. Ne vom baza pe o implementare de listă simplu inlanțuită.
/* List entry */
struct cel{
/* Pointer catre segment */
void* info;
/* Dimensiunea segmentului header + date */
int info_len;
/* Numar de secventa segment */
int seq;
struct cel* next;
};
typedef struct cel list_entry;
/* Window as a list */
typedef struct {
int size;
int max_seq;
list_entry* head;
}list;
/* Adauga in lista un segment. In info este stocat segmentul, len este dimensiunea
segmentului si seq numarul de secventa */
void add_list_elem(list* window, void* segment, int len, int seq);
void display_list_seq(list* window);
list* create_list(int max_size);
Vizual, implementarea arata astfel:
Slot-urile gri pot conține sau nu segmente.
Vom presupune ca am determinat window_size = max_seq - min_seq în funcție de BDP.
Atât la început, cât și la retransmisie, în implementarea de Go-Back-N vom
transmite toata fereastra.
/* Functia trimite toate pachetele din fereastra */
int send_window(list *window) {
list_entry* e = window->head;
while(e != NULL && e->seq < window->max_seq)
sendto(sockfd, e->info, e->info_len, &serveraddr, sizeof(serveraddr));
}
Ce facem atunci când primim un ACK cu numărul de secvență seq? Etichetăm segmentul ca fiind primit prin faptul ca îl scoatem din listă.
/* Scoate din lista segmentele care au fost primite */
int update_left_window(list *window, int seq) {
list_entry* e = window->head;
list_entry *prev = NULL;
int count = 0;
while(e != NULL && e->seq <= seq) {
prev = e;
e = e->next;
free_entry(e);
count++;
}
l->head = e;
return count;
}
Acum ca fereastra s-a eliberat de count segmente. Noul max_seq = max_seq + count, așa
că trebuie să mutăm fereastra la dreapta și să trimitem urmatoarele segmente.
int send_new_segments(list *window, int new_max_seq) {
list_entry* e = window->head;
/* Sarim peste segmentele deja trimise */
while(e != NULL && e->seq <= window->max_seq)
e = e->next;
if (e != NULL) {
/* Trimitem urmatoarele segmente pana la new_max_seq */
while(e != NULL && e->seq <= new_max_seq) {
e = e->next;
sendto(sockfd, e->info, e->info_len, &serveraddr, sizeof(serveraddr));
}
}
window->max_seq = new_max_seq;
}
Numarul de secventa
In general, vom implementa numarul de secventa incremental dupa numarul de segmente:
Sau incremental dupa numarul de bytes:
Exercitii
Scheletul laboratorului se regaseste la adresa de aici. Funcționalitatea disponibilă este descrisă în README.md. În continuare, vom folosi Mininet pentru a simula o topologie simplă:
sudo python3 topo.py.
Antetul protocolului pe care îl dezvoltăm astăzi este descris mai jos. Protocolul îl vom implementa peste UDP.
|Seq Number|Length|
1. Vom dezvolta un protocol simplu de retransmisie. Vom trimite un segment, dacă primim un ACK, trimitem segmentul cu secvența precedentă + 1. Vom folosi o opțiune specială pe socket pentru a seta un timer de o secundă pe el. Astfel, dacă recv nu primește date într-un anumit timp, o să expire timer-ul și o să retransmitem toate segmentele din fereastra.
struct timeval timeout;
timeout.tv_sec = 1;
timeout.tv_usec = 0;
if (setsockopt (sockfd, SOL_SOCKET, SO_RCVTIMEO, &timeout,
sizeof timeout) < 0
error("setsockopt failed\n");
Vom rula Wireshark pe router pentru a face depanarea.
2. Vom extinde implementarea precedentă de protocol pentru a folosi tehnica de fereastra glisantă. Vom implementa Go-Back-N. Vom calcula dimensiunea ferestrei de transmisie în funcție de lungimea de bandă și latență.
3 Ne vom conecta toți în aceași rețea (e.g. pe acelasi WiFi) pentru
a trimite fișiere către alți colegi din sala. Nu vom mai rula peste Mininet.
Ne vom grupa câte doi pentru a trimite un fișier de la unul la celalalt.
Mai exact, unul dintre voi va porni un server cu bind pe INADDR_ANY
(0.0.0.0), iar clientul va putea trimite catre ip-ul celuilalt fisierul.
Note
Dacă folosiți mașini virtuale și nu aveți rețeaua pe modul Bridge, va
trebui să faceți port forwarding din setarile hypervisorului. Pentru
a verifica dacă sunteți în aceeasi retea, folositi ip address show și confirmați că adresa este din același subnet cu cel al gazdei.
Lectură laborator
De parcurs înainte de laborator:
- TCP: Transmission control protocol (8 min)
- TCP connection walkthrough 9 min
- poll()—Synchronous I/O Multiplexing
Protocolul TCP
TCP (Transport Control Protocol) este un protocol ce furnizează transmisie garantată (cât timp există conexiune), în ordine și o singură dată, a octeţilor de la transmiţător la receptor. Acest protocol asigură stabilirea unei conexiuni între cele două calculatoare pe parcursul comunicaţiei și este descris în RFC 793. Protocolul TCP are următoarele proprietăţi:
- stabilirea unei conexiuni între client și server; serverul va aștepta apeluri de conexiune din partea clienților
- garantarea ordinii primirii mesajelor şi prevenirea pierderii pachetelor
- controlul congestiei (fereastră glisantă)
- overhead mai mare în comparaţie cu UDP (are un header de 20 bytes, spre deosebire de UDP, care are doar 8 bytes).
Header TCP
Explicaţii header:
- portul sursă este ales random de către maşina sursă a pachetului, dintre porturile libere existente pe acea maşină
- portul destinaţie este portul pe care maşina destinaţie poate recepţiona pachete
- checksum este valoarea sumei de control pentru un pachet TCP
Pentru a înțelege mai bine cum funcționează protocolul TCP, vom rula Wireshark
pe pe interfata loopback.
Vom deschide un server TCP simplu folosind nc:
nc -l 8083
Din alt terminal ne vom conecta la acest server
telnet -4 localhost 8083
În primele 3 pachete TCP, putem observa operația de three-way handshake între client (browser) și server. În acest caz, observăm că numărul de secvență atât la server cât și la client pornește de la 0 (SEQ = 0, ACK = 0). Daca scriem in telnet text si apasam Enter o sa putem vedea in Wireshark segmentele trimise de TCP.
Sockets API for TCP
La laboratorul precedent am discutat funcțiile socket, bind, recvfrom și sendto pe care le puteam folosi pentru a trimite datagrame UDP. În acest laborator, vom folosi trei funcții noi: connect, listen si accept. Acestea sunt folosite pentru stabilirea unei conexiuni între sender și receiver.
În plus, în cadrul acestui laborator vom folosi funcțiile send și rev în locul funcțiilor recvfrom si sendto deoarece odată stabilită o conexiune, nu mai trebuie să specificăm destinația. Găsiți în imaginea de mai jos un overview a cum sunt realizate acestea.
Note
In cadrul functie socket vom folosi SOCK_STREAM ca argument in locul SOCK_DGRAM.
connect()
În client, după ce am creat socketul, acesta trebuie să se conecteze la server (e.g. sa inițieze și să stabilească un three-way handshake). Pentru asta vom folosi funcția connect():
#include <sys/types.h>
#include <sys/socket.h>
int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
sockfdeste un descriptor de fişier obţinut în urma apeluluisocket(),addreste o structură ce conţine portul şi adresa IP ale serverului,addrlenreprezint[] dimensiunea celui de-al doilea parametru.
Rezultatul întors de connect este 0 în caz de success și -1 în caz de eroare.
listen()
Comunicaţia prin conexiune stabilă este asimetrică. Mai precis, unul din cele două procese implicate joacă rol de server, iar celălalt joacă rol de client. Cu alte cuvinte, serverul trebuie să îi asocieze socketului propriu o adresă pe care oricare client trebuie să o cunoască, şi apoi să "asculte" pe acel socket cererile ce provin de la clienţi. Mai mult decât atât, în timp ce serverul este ocupat cu tratarea unei cereri, există posibilitatea de a întârzia cererile ce provin de la alţi clienţi, prin plasarea lor într-o coadă de aşteptare. Setarea unui socket pentru a fi pasiv se face prin intermediul funcției neblocante slisten():
#include <sys/types.h>
#include <sys/socket.h>
int listen(int sockfd, int backlog);
/* Usage example: After calling bind in the server, we listen at most 5 connections */
if ((listen(sockfd, 5)) != 0) {
printf("Listen failed...\n");
exit(0);
}
sockfdreprezintă descriptorul de fişier obţinut în urma apeluluisocket(),backlogindică numărul de conexiuni acceptate în coada de aşteptare.
Conexiunile care se fac de către clienți vor aştepta în aceasta coadă până când se face accept().
Nu pot fi mai mult de backlog conexiuni în aşteptare.
Apelul listen() întoarce 0 în caz de success şi -1 în caz de eroare.
accept()
Ce se întâmplă în momentul în care un client încearcă să apeleze connect() către o maşină şi un port pe care s-a facut în prealabil listen()?
Conexiunea va fi pusă în coada de aşteptare până în momentul în care se face un apel de accept() de către server. Acest apel întoarce un nou socket care va fi folosit pentru conexiune:
#include <sys/types.h>
#include <sys/socket.h>
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
/* Usage example: after calling listen we can call accept to accept a connection from the queue */
int len;
struct sockaddr_in cli;
/* cli and len are written by the call with the info about the connected client (e.g. port, address) */
connfd = accept(sockfd, (struct sockaddr *)&cli, &len);
sockfdreprezintă socketul pe care s-a făcutlisten()(deci cel întors de apelulsocket())addrreprezintă un pointer spre o structură de tipstruct sockaddrîn care se va afla informaţia despre conexiunea făcuta (ce maşină de pe ce port a iniţiat conexiunea). Noul socket obţinut prin apelulaccept()va fi folosit în continuare pentru operaţiile de transmisie și recepție de dateaddrlenreprezintă dimensiunea (în bytes) a structuriaddr
Funcția accept() întoarce un nou socket, care va fi folosit pentru operațiile de tipul send() / recv().
send/recv
Aceste două funcţii se folosesc pentru a transmite date prin sockeţi de tip stream sau sockeţi de tip datagramă conectaţi. Sintaxa pentru trimitere şi primire este asemănătoare. Pentru trimitere, se folosește funcția send():
#include <sys/types.h>
#include <sys/socket.h>
ssize_t send(int connfd, const void *buf, size_t len, int flags);
Note
Atenție la socket-ul pe care send îl primeste.
Acesta este socket-ul returnat de accept de la un client.
sockfd îl folosim doar pentru a primi conexiuni de la clienți.
connfdeste socketul căruia se dorește să se trimită date (fie este returnat de apelulsocket(), fie de apelulaccept()).bufeste un pointer către adresa de memorie unde se găsesc datele ce se doresc a fi trimise,lenreprezintă numărul de octeți din memorie începand de la adresa respectivă ce se vor trimite.flagsreprezintă o combinație de flag-uri ce poate altera modul de transmitere al mesajului.
Functia întoarce numărul de octeți efectiv trimiși (acesta poate fi mai mic decât numărul care s-a precizat că se dorește a fi trimis, adică len).
În caz de eroare, funcția returnează -1, setându-se corespunzător variabila globală errno.
Pentru recepție de date, se folosește funcția recv():
#include <sys/types.h>
#include <sys/socket.h>
ssize_t recv(int connfd, void *buf, size_t len, int flags);
connfdreprezintă socketul de unde se citesc datele,bufreprezintă un pointer către o adresă din memorie unde se vor scrie octeții citiți,lenreprezintă numărul maxim de octeți ce se vor citi.
Funcția recv() întoarce numărul de octeți efectiv citiți în buf sau -1 în caz de eroare.
Observații:
recv()poate întoarce și 0, acest lucru însemnând că entitatea cu care se comunică a închis conexiunea- pentru scrierea/citirea în/din sockeți TCP, se pot folosi cu succes și functiile
write()șiread(foarte asemănătoare cusend()șirecv()), mai puțin câmpulflags, care va fi setat la valoarea 0
Exemplu client
Pentru a înțelege mai bine, vom studia următoarea implementare de server și client ce folosește API-ul de sockeți:
- Client code, sends hello world message
#include <stdio.h>
#include <string.h>
#include <sys/socket.h>
#include <arpa/inet.h>
int main(void)
{
int socket_desc;
struct sockaddr_in server_addr;
char server_message[2000], client_message[2000];
/* Clean buffers and structures*/
memset(server_message,'\0',sizeof(server_message));
memset(client_message,'\0',sizeof(client_message));
memset(&server_addr, 0, sizeof(server_addr));
/* Create socket, we use SOCK_STREAM for TCP */
socket_desc = socket(AF_INET, SOCK_STREAM, 0);
if(socket_desc < 0){
printf("[CLIENT] Unable to create socket\n");
return -1;
}
printf("[CLIENT] Socket created successfully\n");
/* Set port and IP the same as server-side */
server_addr.sin_family = AF_INET;
server_addr.sin_port = htons(2000);
server_addr.sin_addr.s_addr = inet_addr("127.0.0.1");
/* Send connection request to server */
if(connect(socket_desc, (struct sockaddr*)&server_addr, sizeof(server_addr)) < 0){
printf("[CLIENT] Unable to connect\n");
return -1;
}
printf("[CLIENT] Connected with server successfully\n");
/* Get input from the user */
printf("[CLIENT] Enter message: ");
gets(client_message);
/* Send the message to server */
if(send(socket_desc, client_message, strlen(client_message), 0) < 0){
printf("[CLIENT] Unable to send message\n");
return -1;
}
/* Receive the response from server */
if(recv(socket_desc, server_message, sizeof(server_message), 0) < 0){
printf("[CLIENT] Error while receiving server's msg\n");
return -1;
}
printf("[CLIENT] Server's response: %s\n",server_message);
/* Close the socket */:
close(socket_desc);
return 0;
}
Server code
#include <stdio.h>
#include <string.h>
#include <sys/socket.h>
#include <arpa/inet.h>
int main(void)
{
int socket_desc, client_sock, client_size;
struct sockaddr_in server_addr, client_addr;
char server_message[2000], client_message[2000];
/* Clean buffers and structures*/
memset(server_message, 0, sizeof(server_message));
memset(client_message, 0, sizeof(client_message));
memset(&server_addr, 0, sizeof(server_addr));
memset(&client_addr, 0, sizeof(client_addr));
/* Create socket */
socket_desc = socket(AF_INET, SOCK_STREAM, 0);
if(socket_desc < 0){
printf("[SERV] Error while creating socket\n");
return -1;
}
printf("[SERV] Socket created successfully\n");
/* Set port and IP that we'll be listening for, any other IP_SRC or port will be dropped */
server_addr.sin_family = AF_INET;
server_addr.sin_port = htons(2000);
server_addr.sin_addr.s_addr = inet_addr("127.0.0.1");
/* Bind to the set port and IP */
if(bind(socket_desc, (struct sockaddr*)&server_addr, sizeof(server_addr)) < 0) {
printf("[SERV] Couldn't bind to the port\n");
return -1;
}
printf("[SERV] Binding completed successfully\n");
/* Listen for clients */
if(listen(socket_desc, 1) < 0) {
printf("Error while listening\n");
return -1;
}
printf("[SERV] Start listening for incoming connections.....\n");
/* Accept an incoming connection from one of the clients */
client_size = sizeof(client_addr);
client_sock = accept(socket_desc, (struct sockaddr*)&client_addr, &client_size);
if (client_sock < 0){
printf("Can't accept\n");
return -1;
}
printf("[SERV] Client connected at IP: %s and port: %i\n",
inet_ntoa(client_addr.sin_addr), ntohs(client_addr.sin_port));
/* Receive message from clients. Note that we use client_sock, not socket_desc */
if (recv(client_sock, client_message, sizeof(client_message), 0) < 0){
printf("[SERV] Couldn't receive\n");
return -1;
}
printf("[SERV] Received message from client: %s\n", client_message);
/* Write a response to the client */
strcpy(server_message, "This is the server's message.");
if (send(client_sock, server_message, strlen(server_message), 0) < 0){
printf("[SERV] Can't send\n");
return -1;
}
/* Close the sockets */
close(client_sock);
close(socket_desc);
return 0;
}
</spoiler>
Multiplexare I/O
Am întâlnit trei tipuri de apeluri blocante, care sunt de fapt citiri din descriptori (de sockeți sau fișiere):
accept()- citire de pe socketul inactiv pe care ascultă serverulrecv()/recvfrom()- citire de pe sockeți activiscanf()/gets()/read(0, ...)- citire de la tastatură.
Intalnim o problemă comună: un program se află blocat într-o citire pe un descriptor, dar primește date pe un alt descriptor.
Ce facem daca vrem sa monitorizam trei socketi in acelasi timp pentru date? Avem nevoie de un mecanism care să ne permită să citim exact de pe descriptorul pe care au venit date.
Soluția este reprezentată de funcția poll(), care ajută la controlarea mai multor descriptori (de fișiere sau sockeți) în același timp.
#include <poll.h>
int poll(struct pollfd fds[], nfds_t nfds, int timeout);
Argumentele funcției select():
fds- mulțimea de file descriptori monitorizaținfds- numărul de elemente din vectorulfdstimeout- timpul maxim în care apelulpoll()trebuie să întoarcă, exprimat în milisecunde. (o valoare negativă specifică o așteptare indefinită până la apariția unui eveniment, în timp ce o valoare nulă presupune întoarce imediată a apelului depoll()).
Structura pollfd este definită în sys/poll.h:
#include <sys/poll.h>
struct pollfd {
int fd; /* file descriptor */
short events; /* evenimente solicitate */
short revents; /* evenimente apărute */
};
În cadrul structurii pollfd avem:
-
eventseste o mască de biți în care se specifică evenimentele urmărite depollpentru descriptorulfd:POLLIN- există date ce pot fi cititePOLLOUT- se pot scrie date
-
reventseste, de asemenea, o mască de biți completată de kernel- cu evenimentele apărute în momentul în care apelul se întoarce(
POLLIN,POLLOUT) - cu valori predefinite (
POLLERR,POLLHUP,POLLNVAL) pentru situații speciale.
- cu evenimentele apărute în momentul în care apelul se întoarce(
În caz de succes, funcția returnează un număr diferit de zero reprezentând numărul de structuri pentru care revents nu e zero (cu alte cuvinte toți descriptorii cu evenimente sau erori).
Se returnează 0 dacă a expirat timpul (timeout milisecunde) și nu a fost selectat nici un descriptor.
Exemplu de folosire poll
#define MAX_PFDS 32
[...]
struct pollfd pfds[MAX_PFDS];
int nfds;
int listenfd, sockfd; /* listener socket; connection socket */
nfds = 0;
/* read user data from standard input */
pfds[nfds].fd = STDIN_FILENO;
pfds[nfds].events = POLLIN;
nfds++;
/* TODO ... create server socket (listener) */
/* add listener socket */
pfds[nfds].fd = listenfd
pfds[nfds].events = POLLIN;
nfds++;
while (1) { /* server loop */
/* wait for readiness notification */
poll(pfds, nfds, -1);
if ((pfds[1].revents & POLLIN) != 0) {
/* TODO ... handle new connection */
}
else if ((pfds[0].revents & POLLIN) != 0) {
/* TODO ... read user data from standard input */
}
else {
/* TODO ... handle message on connection sockets */
}
}
[...]
Timere
În laborator ni se solicită trimiterea unui mesaj periodic. Pentru a realzia acest lucru
vom folosi API-ul de timerfd din Linux.
Timerfd este un file descriptor și este creat folosind timerfd_create. Functia timerfd_settime îi setează
intervalul la care să ne anunțe (1 secunda in acest caz).
#include <sys/time.h>
#include <sys/timerfd.h>
int timerfd;
timerfd = timerfd_create(CLOCK_REALTIME, 0);
struct itimerspec spec;
spec.it_value.tv_sec = 1;
spec.it_value.tv_nsec = 0;
spec.it_interval.tv_sec = 1;
spec.it_interval.tv_nsec = 0;
timerfd_settime(timerfd, 0, &spec, NULL);
Daca facem read pe timerfd, acesta se va bloca până când va expira timpul setat de timerfd_settime() (în cazul acesta o secundă).
uint64_t count;
read(timerfd, &count, sizeof(count));
Astfel, când read s-a deblocat, înseamnă că timpul a expirat, iar valoarea de tipul uint64_t citită va fi egală cu numărul de ori de care a expirat timmer-ul, de la ultima resetare (ex. pentru timmer-ul de o secundă, după 1 minut, valoarea va fi 60).
Exerciții
In cadrul laboratorului de astazi vom implementa o aplicatie de chat precum Whatsapp.
Descrierea scheletului
Pentru implementarea cerințelor, vom porni de la acest schelet de cod.
În scheletul de laborator găsiți implementată o aplicație client-server care implementează o funcție de chat, care funcționează astfel:
- După pornire, serverul așteaptă conectarea a doi clienți
- După ce ambi clienți s-au conectat, aceștia trimit mesaje către server, pentru a fi trimise către celălalt client.
- La primirea unui mesaj, clientul afișează ce a primit.
Aplicația din schelet are câteva neajunsuri pe care le vom corecta.
Cerinte
Task 1
Note
TCP tratează informația transmisă ca pe un flux de date (octeții in ordine) și are un proces complex (cu ACK-uri, timere, etc.) care trebuie ascuns de utilizator (pentru a fi ușor de folosit). Astfel, apelul send() nu determină trimiterea imediată a unui mesaj/segment, ci face append datelor oferite la un buffer local de transmitere, trimiterea și recepționarea segmentelor de date fiind administrată de către stiva TCP/IP (implementată în kernel) în mod asincron.
La rândul său, recv() se deblochează atunci când orice cantitate de informație devine disponibilă în buffer-ul de recepție, neexistând o încadrare implicită (în mesaje) a octeților din flux.
În consecință, dacă protocolul de nivel aplicație vede transmisia ca fiind alcătuită din mesaje, atunci trebuie să ne așteptăm că este posibil să apară oricând:
- trunchieri - din niște octeți trimiși de către o aplicație, în procesul de la distanță devine disponibilă doar o parte din ei, și totuși apelul
recv()se întoarce, punând la dispoziția apelantului doar o parte din mesaj - concatenări - mai mult de un mesaj se citește cu un singur apel de
recv().
Pentru a fi simplă identificarea mesajelor, implementarea din schelet trimite informația în calupuri de lungime fixată (sizeof(struct chat_meesage)), însă acest lucru nu este suficient deoare funcțiile recv() și send() pot recepționa / copia pentru trimitere mai puțini octeți decât am dat ca parametru.
Reimplementați funcțiile din common.h send_all() și recv_all() astfel încât să acoperiți acest neajuns.
Task 2
Alte neajunsuri ale aplicației sunt că:
- Aceasta funcționează doar cu exact 2 clienți;
- Clienții au o ordine fixată a comunicației (clientul
xnu poate da mesaje când este rândul clientuluiy); - Ambii clienți întâi citesc un mesaj de la tastatură, apoi așteaptă să vadă ce primesc de la server. Asta face ca interactivitatea să fie afectată. Este un comportament așteptat înainte să rezolvăm task-ul 2.
Folosind API-ul de multiplexare (de exemplu, select(), poll() sau epoll()), urmăriți TODO-urile pentru a realiza o comunicare de tip chat între mai mulți clienți.
Funcționalități pentru a valida soluția:
- serverul va accepta noi clienți în orice moment;
- clienții vor putea comunica în orice ordine;
- un mesaj primit de la un client va fi trimis către toți ceilalți, iar aceștia îl vor afișa imediat.
Task 3
La fiecare 4 secunde, server-ul va trimite un anunt despre abonamentele premium catre toti clientii: "Dragi clienti, pentru doar 12 lei o sa puteti trimite de 10 ori mai multe mesaje in jumatate din timp". Vom folosi un timerfd pentru a implementa aceasta functionalitate.
Lectură laborator
De parcurs înainte de laborator:
Colapsul congestiei din 1986
În octombrie 1986, a fost detectată o prăbușire a congestiei pe Internet pe o legătură de 32 kbps între campusul Universității din California, Berkeley și Laboratorul Național Lawrence Berkeley, aflat la 400 de metri distanță, în timpul căreia debitul a scăzut cu un factor de aproape 1.000, ajungând la 40 bps.
Doi ani mai târziu, Van Jacobson a implementat și publicat algoritmul de control al congestiei în versiunea Tahoe a TCP, bazată pe o idee a lui Raj Jain, K.K. Ramakrishnan și Dah-Ming Chiu. Înainte de Tahoe, existau mecanisme în TCP care împiedicau expeditorii să copleșească receptorii (Flow Control), dar nu exista niciun mecanism eficient care să împiedice expeditorii să copleșească rețeaua. Acest lucru nu a fost o problemă deoarece existau puțini gazde, până la mijlocul anilor 1980. Până în noiembrie 1986, numărul de gazde a fost estimat să fi crescut la 5.089, dar majoritatea legăturilor de bază au rămas la 50 - 56 bps (biți pe secundă) de la începutul ARPANet.
In figura de mai jos gasim reprezentare grafica a traficului care dupa un punct face colaps.
Controlul Congestiei
Am văzut în laboratoarele precedente că dimensiunea ferestrei transmițătorului
era calculată în funcție de BDP.
În cazul în care considerăm că
dimensiunea maximă a unui segment este Maximum Segment Size (MSS), atunci am
putea calcula dimensiunea optimă a ferestrei ca fiind BDP / MSS. Totuși, ce
facem atunci când avem mai mulți transmițători ce împart același link?
Fie următoarea topologie în care avem 2 transmițători care împart un link către H3.
Dacă atât H1 cât și H2 ar avea un throughput de transmisie de 50 Mb/s, atunci am ajunge la 100 Mb/s pe link-ul către H3, ce are o capacitate de doar 50 Mb/s. Acest lucru va rezulta în pierderea segmentelor și retransmisia.
Pierderile apar de la faptul ca buffer-ul din router se umple. El poate trimite catre
h2cu 50 Mb/s in timp ce primeste pachete la 100 Mb/s de lah1sih3.
Pentru a evita acest colaps al rețelei cauzat de congestie, transmițătorul va trebui să își limiteze dimensiunea ferestrei de transmisie. În acest scop, introducem Congestion Window (CWND), ce reprezintă fereastra de congestie, care este numarul de octeti pe care transmitatorul ii poate trimite fara a astepta o confirmare.
Fereastra de congestie este exprimata de obicei in octeti pentru a permite folosirea segmentelor de dimensiuni variabile de catre transmitator. Alternativ, ea poate fi exprimata in unitati, unde fiecare unitate reprezinta un segment de dimensiune maxima (MSS, sau Maximum Segment Size. In Internet MSS este in jurul 1500B).
Fereastra de congestie este actualizata dinamic de catre transmitator. Fereastra va creste atunci cand nu exista congestie, si va fi redusa atunci cand reteaua este congestionata. Valoarea minima a ferestrei este de 1MSS.
Slow Start
Algoritmul de Slow Start pornește cu o valoare a CWND = IW * MSS unde IW reprezintă Initial Window și este setat la 10, conform RFC6928.
La fiecare confirmare primită, Slow Start crește fereastră cu un MSS.
CWND = CWND + MSS
Astfel, fereastră se dublează în fiecare round-trip time în timpul slow start (după 1RTT ea va ajunge 20MSS, după încă unul 40MSS, etc.) Slow start se încheie atunci când se detectează congestie în rețea, fie ca urmare a pierderii unui pachet, fie atunci când rețeaua indică explicit congestia cu ajutorul ECN (explicit congestion notification).
Trecerea la algoritmul de congestie. Introducem un prag (threshold), ssthresh după care o să trecem la utilizarea unui algoritm de congestie precum AIMD pentru
actualizarea CWND. Inițial sstresh are o valoare mare, dar la fiecare timeout acesta este actualizat
ssthresh = CWND/2. Atunci când CWND > sstresh transmițătorul face trecerea la AIMD.
Additive Increase, Multiplicative Decrease (AIMD)
Un posibil algoritm de evitare a congestiei este AIMD. Algoritmul crește fereastra de congestie cu un MSS per Round Trip Time (RTT).
Există două implementări posibile pentru partea de creștere a ferestrei: actualizarea ferestrei la fiecare ACK, sau o dată per RTT.
Algoritmul pentru actualizare la fiecare ACK este mai simplu de implementat pentru că nu necesită menținerea unei variabile suplimentare care să detecteze când a trecut un RTT. Să presupunem că fereastra de congestie este menținută în octeti. În acest caz, la primirea unui ACK, fereastra de congestie crește astfel.
onAck(bytes_acked - numărul de octeți confirmați de receptor)
if (CWND > ssthresh) //additive increase
CWND = CWND + bytes_acked * MSS / CWND
else //slow start
CWND = CWND + MSS
De-a lungul unui round-trip time, suma bytes_acked va fi egală cu CWND; astfel, creșterea totală va fi MSS per RTT (additive increase).
Variabila ssthresh face trecerea dintre Slow Start și Additive Increase. Atunci când conexiunea începe, Slow Start va avea valoarea MAXINT. După fiecare pierdere, ssthresh este actualizată pentru a memora o valoare "sigură" a ferestrei de congestie; atunci când valoarea CWND este sub ssthresh, creșterea este exponențială. Aceasta asigura că fereastra crește rapid la începutul conexiunii pentru a utiliza rapid capacitatea rețelei.
Atenție!
Dacă toate variabilele folosite sunt întregi, există riscul ca atunci când CWND are valori mari, creșterea bytes_acked * MSS să fie mai mică decât CWND și astfel fereastră să nu mai crească! Pentru a evita astfel de erori de rotunjire, se vor folosi valori floating point pentru CWND.
Atunci când un packet este pierdut, fereastra de congestie este redusă la jumătate (multiplicative decrease). De notat este că se va reduce fereastră o dată per loss event - chiar dacă se pierd mai multe pachete într-un RTT, fereastra este redusă o singură dată.
onLoss(detectată cu ack-uri duplicate)
CWND = CWND / 2
ssthresh = CWND
Atunci când nu primim confirmarea pentru un packet și expiră timer-ul, se va execută codul de mai jos care reduce CWND și mai agresiv:
onTimeout():
ssthresh = CWND/2
CWND = 1 MSS
Comportamentul dat de AIMD de evitare a congestiei este surprins în figura de mai jos și se mai numește "dinți de fierăstrău".
Etapele TCP
În figura de mai jos este surprins comportamentul TCP ce folosește Slow Start și algoritmul AIMD de evitare a congestiei.
Animatia de mai jos prezinta comportamentul TCP. Pe măsură ce fereastra de congestie crește, viteza la care pachetele (albastre) sunt trimise crește de asemenea, până când utilizarea legăturii limită atinge 100%. Apoi, pe măsură ce rata pachetelor trimise continuă să crească, pachetele încep să se acumuleze în buffer. În cele din urmă, bufferul devine plin și routerul trebuie să renunțe la pachetele noi.
Când expeditorul ia la cunoștință de pachetul abandonat (deoarece nu primește un ACK pentru acesta), își reduce fereastra de congestie cu un factor multiplicativ. Cu o fereastră de congestie mai mică și multe pachete neconfirmate deja "în zbor", acesta trebuie să facă o pauză înainte de a putea relua transmisia, astfel încât bufferul să aibă ocazia să se golească. Odată ce trece ceva timp și mai multe dintre segmentele "în zbor" sunt confirmate, expeditorul poate relua transmisia și poate începe să-și mărească din nou fereastra de congestie. Acest proces continuă pe parcursul întregii durate de viață a fluxului, ducând la un model clasic de "dinti de fierăstrău".
Iperf
Iperf este unul dintre cele mai populare programe folosite pentru măsurarea performanței în networking.
Pentru a porni iperf în modul server TCP vom folosi:
iperf -s
Pentru a porni clienți TCP folosind iperf, vom folosi:
iperf -c $IP_SERVER
Serverul va afișa throughput-ul măsurat pentru fiecare conexiune de la client. Mai jos gășiți un exemplu de output.
> iperf -s
------------------------------------------------------------
Server listening on TCP port 5001
TCP window size: 85.3 KByte (default)
------------------------------------------------------------
[ 1] local 127.0.0.1 port 5001 connected with 127.0.0.1 port 57374 (icwnd/mss/irtt=215/22016/23)
[ ID] Interval Transfer Bandwidth
[ 1] 0.00-2.00 sec 14.3 GBytes 61.3 Gbits/sec
❯ iperf -c 127.0.0.1 -t 2
------------------------------------------------------------
Client connecting to 127.0.0.1, TCP port 5001
TCP window size: 2.50 MByte (default)
------------------------------------------------------------
[ 1] local 127.0.0.1 port 57374 connected with 127.0.0.1 port 5001 (icwnd/mss/irtt=213/21845/52)
[ ID] Interval Transfer Bandwidth
[ 1] 0.00-2.01 sec 14.3 GBytes 61.1 Gbits/sec
Iperf poate funcționa și cu UDP, trebuie doar folosit flag-ul.
-u. Pentru a seta rata de transmisie (throughput) în UDP vom folosi:-b(biți/s).
Socket Stats
Socket stats (ss) este un utilitar ce afișează pentru fiecare conexiune TCP parametrii din transmission control block (TCB) precum CWND, thrghouput etc.
> ss -tin
State Recv-Q Send-Q Local Address:Port Peer Address:PortProcess
ESTAB 28 1475512 192.168.1.100:59480 172.16.0.100:5001
cubic wscale:9,9 rto:414 rtt:213.93/0.851 ato:40 mss:1448 pmtu:1500
rcvmss:536 advmss:1448 cwnd:187 ssthresh:158 bytes_sent:1330772
bytes_acked:1061445 bytes_received:28 segs_out:923 segs_in:263
data_segs_out:920 data_segs_in:1 send 10125779bps lastsnd:1 lastrcv:886
lastack:1 pacing_rate 12150896bps delivery_rate 9562208bps delivered:735
busy:890ms rwnd_limited:104ms(11.7%) unacked:186 rcv_space:14480
rcv_ssthresh:42242 notsent:1206184 minrtt:4.052
Exerciții
Scheletul laboratorului se regaseste la adresa de aici. Funcționalitatea disponibilă este descrisă în README.md. În continuare, vom folosi Mininet pentru a simula o topologie formată din 3 host-uri conectate la un router:
sudo python3 topo.py.
Astăzi vom folosi matplotlib pentru a desena grafice.
sudo apt install python3-pip
sudo pip3 install matplotlib
Astazi vom studia comportamentul TCP in mai multe situatii, vom urmarii live cum TCP actualizeaza CWND si cum acest lucru afecteaza throughput-ul.
Vă punem la dispoziție un script de Python, run_client.py, care rulează un client de iperf și desenează un grafic interactiv cu throughput-ul și congestion window-ul (cwnd) clientului. Adresa IP a lui H2 este ip address show -> 172.16.0.100. Din topo.py puteți modifica parametrii link-urilor.
1. Pe h2 rulați un server de iperf. Pe h1 rulați scriptul run_client.py și urmăriți graficul. După
30 de secunde deschideți pe h3 un al doilea client de iperf. Urmăriți cele două grafice, ce se întâmplă cu
throughput-ul și cwnd? Cum sunt impartite intre h1 si h3? Modificati valorile bandwidth-ului la 30Mbps,
se modifica comportamentul?
2. Vom reproduce scenariul anterior, doar că pe H1 vom rula scriptul run_client.py, iar pe h3
vom porni n instanțe de clienți folosind iperf -c 172.16.0.100. Ce putem spune că se întâmplă în acest caz?
3. De data aceasta vom rula două servere iperf pe h2, unul de UDP și unul de TCP. Pe h1 vom
rula run_client.py, iar pe h3 vom rula un client de UDP cu un bandwidth (-b) cu valori cuprinse
între 1 Mb - 10 Mb. Ce observăm?
4. Parcurgeti subsectiunea Modeling TCP congestion control. Vom modifica delay-ul din topo.py pe link-ul dintre router si h3 sa fie 5ms.
Cum va fi impartit throughput-ul intre h1 si h3?
Hint: și
5. Vom face un client de iperf. Acesta este un simplu program ce se conectează la un server, trimite un număr de bytes pentru t secunde și la final afișează throughput-ul.
Lectură laborator
De parcurs înainte de laborator:
- The HyperText Transfer Protocol
- Seria de articole de la echipa Chrome - Inside look at modern web browser
Protocolul HTTP
Miliarde de imagini JPEG, pagini HTML, fișiere text, filme în format MPEG, fișiere audio WAV, applet-uri Java și multe altele sunt accesate pe internet în fiecare zi. HTTP este protocolul responsabil cu mutarea acestora rapid, convenabil și fiabil de la serverele web din întreaga lume la browserele web ale utilizatorilor. Deoarece HTTP, este un protocol peste TCP, datele transmisie nu vor fi deteriorate sau amestecate sau pierdute în timpul tranmisiei de date.
HTTP (HyperText Transfer Protocol) este un protocol de nivel 7 din stiva OSI (aplicatie) folosit pentru transferul informatiilor in internet. Este un protocol care opereaza peste date de tip ASCII.
La baza protocolului HTTP stau conceptele de cerere si raspuns. In cazul comunicatiei HTTP, o entitate inainteaza o cerere si cealalta trebuie, obligatoriu, sa ofere un raspuns.
Probabil că utilizați clienți HTTP în fiecare zi. Cel mai comun client este un browser web (de ex. Google Chrome, Mozilla, Internet Explorer, Safari, etc.). Browserele web sunt entitățiile care solicită artefacte HTTP de la servere și le afișează pe ecran. HTTP functioneaza implicit peste portul 80. Versiunea securizata de HTTP, HTTPS, functioneaza implicit peste portul 443. Un server, insa, poate fi configurat sa asculte cereri HTTP pe orice port disponibil.
Cereri HTTP
Cu totii sunteti familiari cu acest format:
Exemplul de mai sus cuprinde:
- versiunea protocolului
- host-ul interlocutorului
- calea de pe serverul interlocutorului unde se va desfasura actiunea
- parametri aditionali de cerere (optionali)
Ce este prezentat in poza nu este o cerere HTTP, ci preambul unei cereri HTTP. De fapt, in momentul in care se da enter, browserul (sau orice alt client) creaza, bazat pe informatiile oferite, cererea HTTP efectiva.
Formatul cererii este urmatorul:
METODA CALE VERSIUNE_PROTOCOL\r\n
Host: HOST\r\n
Header1: Valoare Header1\r\n
Header2: Valoare Header2\r\n
...
Cookie: cheie1=valoare1; cheie2=valoare2; ...; cheieN=valoareN\r\n
\r\n
DATA
Linia de start contine 3 elemente.
Primul element este metoda folosita. Metodele HTTP sunt verbe ce descriu actiunea ce va fi efectuata asupra entitatii catre care se transmite cererea. Cele mai des utilizate cereri sunt:
- GET - interogare de resurse
- POST - aduagare de resurse. De obicei are si date atasate.
- PUT - modificare de resurse. De obicei are si date atasate.
- DELETE - stergere de resurse
Al doilea element este reprezentat de calea si parametrii de cerere (daca exista) unde se va actiona asupra resursei, pe server. In cazul in care exista parametri de cerere, acestia trebuie separati de restul caii prin ?.
Al treilea element este reprezentat de versiunea protocolului de HTTP folosita. Implicit, din motive de securitate, este folosit HTTPS. Pentru varianta ne-securizata, ultima versiune este HTTP/1.1.
A doua linie descrie host-ul entitatii unde va fi transmisa cererea. Host-ul poate sa fie atat un ip cat si un domeniu.
Note
Host-ul este, de fapt, tot un header. Pentru a omite aceasta linie este necesar sa puneti host-ul in cale, exact cum se scrie in browser. Totusi, este indicat sa tratati host-ul ca linie separata.
Headerele sunt scrise cate unul pe rand. Acestea sunt folosite pentru a transmite informatii aditionale catre interlocutor. Headerele se impart in 3 categorii:
- Request Headers - descriu modul in care se face cererea si cum se poate raspunde la ea. Exemplu: User-Agent
- General Headers - descriu informatii cu caracter general care tin de comunicare. Exemplu: Keep-Alive
- Entity Headers - descriu informatii despre datele (daca exista) atasate cererii. Exemplu: Content-Type si Content-Length
Note
Daca exista data atasata cererii, trebuie specificate, obligatoriu, cele doua headere Content-Type si Content-Length
Cookies
Cookies sunt scrise inlantuit, delimitat de punct si virgula (mai putin ultima). Implicit, comunicarea HTTP este considerata stateless. Nu se poate face corelatie intre oricare doua cereri succesive. Cookies retin bucati de informatie trimise de la server catre client, pentru a putea fi refolosite in cereri ulterioare.
Note
Cookiurile sunt artefacte care se salvează doar la Client.
Inaintea datelor (sau la finalul
cererii, daca nu exista date) se pune intotdeauna \r\n\
Data variaza in functie de tipul de data transmis. Cele mai des intalnite tipuri de date transmise sunt:
- text/html - De exemplu, pagini HTML
- application/x-www-form-urlencoded - Date de forma key1=value1&key2=value2&...&keyN=valueN. Datele sunt inlantuite prin "&"
- application/json - Date de forma JSON (Javascript Object Notation). Folosite des in interactiunea cu API-uri
- multipart/form-data - Date binare, de exemplu, fisiere
Pe baza informatiilor prezentate mai sus, o varianta simplificata a cererii catre facebook, din exemplu, ar arata asa:
GET /search/top/?q=programare%20%web%202020 HTTPS\r\n
Host: facebook.com\r\n
User-Agent: Mozilla/5.0\r\n
Connection: keep-alive\r\n
Cookie: c_user=XXXXXXXXXX; presence=XXXXXXX\r\n
\r\n
Exemplu foarte simplu de POST.
POST /test HTTP/1.1
Host: foo.example
Content-Type: application/x-www-form-urlencoded
Content-Length: 27
field1=value1&field2=value2
Note
Este obligatoriu sa puneti \r\n la finalul fiecarui
rand din cerere, cu exceptia datelor atasate.
Un exemplu de implmentare a unei cereri HTTP de tip GET pe baza codului sursă din skeletpoate fi gasit mai jos.
message = compute_get_request(SERVERADDR, "/api/v1/dummy", NULL, NULL, 0);
send_to_server(sockfd, message);
response = receive_from_server(sockfd);
printf("%s\n", response);
Raspunsuri HTTP
Orice cerere HTTP este urmata de un raspuns. Raspunsurile seamana cu cererile din punct de vedere al organizarii. Formatul este urmatorul:
PROTOCOL_VERSION STATUS_CODE STATUS_TEXT\r\n
Header1: Valoare Header1\r\n
Header2: Valoare Header2\r\n
...
HeaderN: Valoare HeaderN\r\n
Set-Cookie: cheie1=valoare1\r\n
Set-Cookie: cheie2=valoare2\r\n
...
Set-Cookie: cheieN=valoareN\r\n
\r\n
DATA
Linia de start contine 3 elemente.
Pentru implemenatrea unei răspuns de tip HTTP POST pe baza codului skelet, trebuie aplelată funcția compute_post_request cu parametrul content_type = application/x-www-form-urlencoded.
Primul element este reprezentat de versiunea protocolului de HTTP folosit pentru a se raspunde.
Al doilea element este reprezentat de statusul raspunsului. Statusul este corelat de reusita, respectiv esecul cererii si de ce s-a intamplat pe entitatea catre care s-a trimis cererea. Exemplu de statusuri des intalnite:
- 200 - OK
- 201 - Created
- 204 - No Content
- 400 - Bad Request
- 401 - Unauthorized
- 403 - Forbidden
- 404 - Resource Not Found
- 500 - Internal Server Error
Al treilea element descrie textul care insoteste statusul.
Headerele urmeaza aceeasi structura si descriu acelasi lucru ca si in cazul cererilor.
Cookies sunt setate cate una pe linie. In afara de cheie=valoare, acestea mai au o serie de atribute atasate, precum secure, httpOnly, domain.
Data urmeaza aceeasi structura ca si in cazul cererilor.
Sesiune si autentificare
O sesiune este definită ca o serie de solicitări legate de browser care provin de la același client într-o anumită perioadă de timp. Urmărirea sesiunii leagă împreună o serie de solicitări de browser - gândiți-vă la aceste solicitări ca pagini - care pot avea o anumită semnificație în ansamblu, cum ar fi o aplicație pentru coșul de cumpărături.
Autentificarea de bază HTTP este o metodă simplă de autentificare pentru client prin care acesta furniza un nume de utilizator și o parolă atunci când efectuează o solicitare către server. Acesta este cel mai simplu mod posibil de a impune controlul accesului, deoarece nu necesită module cookie, sesiuni sau orice altceva. Pentru a utiliza acest lucru, clientul trebuie să trimită antetul de autorizare împreună cu fiecare solicitare pe care o face.
Implementarea mecanismului de autentificare folosind codul skelet implică construirea unui mesaj de tip POST care include cei doi parametrii nume și parolă sub forma unui vectori de stringuri.
Suportul de laborator
Va oferim aici un cod sursă schelet pentru realizarea unui client HTTP scris in C. Aveti deja implementata partea de realizare de conexiune, partea de trimitere, respectiv receptionare bytes de la server in helpers.c si buffer.c.
Fisierele in care voi veti lucra sunt client.c si requests.c. In requests va trebui sa implementati scrierea cererilor de tip GET si POST si in client trebuie sa trimiteti cererea proaspat compusa catre destinatie.
Veti interactiona cu doua servere:
- Serverul principal scris de noi aflat la adresa 34.254.242.81 pe portul 8080
- API-ul oferit de Openweather Map aflat la adresa api.openweathermap.org pe portul 80
Exerciții
Pornind de la codul disponibil aici, aveți de implementat următoarele cerințe:
1. Implementati folosind instrucțiunile din îndrumarul de laborator o cerere dummy de tip GET pentru adresa /api/v1/dummy de la serverul principal.
Note
Gasiti aici un exemplu de request GET.
2. Implementati folosind instrucțiunile din îndrumarul de laborator o cerere dummy de tip POST pentru adresa /api/v1/dummy de la serverul principal cu cu orice conținut pentru date de forma application/x-www-form-urlencoded.
Note
Gasiti aici o captura cu mesaje de tip POST. Mai multe detalii despre POST gasiti aici
3. Ne propunem implementarea unei mecanims de autentificare. Implementați folosind instrucțiunile din îndrumarul de laborator o cerere de tip POST pentru adresa /api/v1/auth/login de pe serverul principal folosind username student si password student. Similar cu task-ul precedent datele trebuie să fie de forma application/x-www-form-urlencoded.
4. Folosind cookie-ul obtinut la pasul precedent, care poate fi hardcodat, implmentați o cerere de tip GET către adresa /api/v1/weather/key a serverului principal pentru a obține un cheia cu care vom obține informații despre vreme de la api.openweathermap.org.
Note
Gasiti aici un exemplu de mesaj GET cu cookie.
5. Folosind cheia obținută la exercițiul anterior, implmentați o cerere de tip GET la serverul Openweather Map pentru a obține datele despre vreme, specificând un set de coordonate (latitudine, longitudine) la alegere.
Note
Mai multe informații despre funcționarea API-ului puteți găsi aici.
6. Cu date obținute la punctul precedent, implementați o cerere de tip
POST la serverul 34.254.242.81, calea /api/v1/weather/{latitudine}/{longitudine} (ex. /api/v1/weather/44.7398/22.2767) pentru verificare.
Pentru acest task, pentru identificarea mai facilă a începutului payload-ului, puteți să țineți cont de faptul că datele servite de
serverul Openweather Map încep cu o acoladă ({), fiind un obiect în formatul JSON și nu vor apărea alte acolade în antetul răspunsului. Desigur, nu este obligatoriu să vă folosiți de asta, parsarea răspunsului HTTP fiind o alternativă cel puțin la fel de bună.
7. Implementați o cerere de tip GET către serverul principal pentru
efectuarea delogări (LogOut) la /api/v1/auth/logout.
Bonus
- Implementați mecanismul necesar de păstrare a cookies-urilor, astfel încât dacă un client deja autentificat încearcă să facă login, el să primească un mesaj de forma "Already logged in!".
- Folositi Postman pentru a testa comenzile HTTP pe care le-ati implementat la laborator. Analizati cererile si raspunsurile primite si identificati elementele din protocol prezentate la curs si laborator.
Lectură laborator
De citit înainte de laborator:
Lectură video
- Email Protocols: SMTP, POP and IMAP (3 min)
- What is DNS (Domain Name System)? (IBM Technology) (8 min)
Lectură opțională
- A Survey of Email Service; Attacks, Security Methods and Protocols
- A survey on malicious domains detection through DNS data analysis
Obiective
În urma parcurgerii acestui laborator, studentul va fi capabil să:
- diferențieze și utilizeze două protocoale pentru citirea poștei electronice;
- folosească protocolul pentru trimiterea de mesaje și atașamente prin poșta electronică;
- scrie un client simplu de e-mail;
- opereze cu ierarhia spațiilor de nume și să identifice tipurile de domenii și subdomenii;
- folosească algoritmul de interogare utilizat de DNS;
- identifice tipurile de resurse pentru diverse domenii și clasele acestora;
- folosească un set minimal de funcții pentru aflarea informațiilor unui sistem gazdă.
Protocolul DNS
DNS folosește în general protocolul UDP pe portul 53, dar, în cazul răspunsurilor de dimensiuni mai mari sau pentru operații ca transferul de zone, se utilizează și TCP. Mai recent, s-a introdus și DNS over HTTPS (sau DoH, descris în RFC 8484), care presupune realizarea de cereri DNS peste HTTPS din motive de securitate.
Spațiul de nume
DNS organizează numele resurselor într-o ierarhie de domenii. Un domeniu reprezintă o colecție de sisteme gazdă care au unele proprietăți în comun, cum ar fi faptul că toate aparțin unei aceleiași organizații sau faptul că toate sunt situate geografic în același perimetru.
Fiecare domeniu este partiționat în subdomenii și acestea sunt la rândul lor, partiționate, ș.a.m.d. Toate aceste domenii pot fi reprezentate ca un arbore, după cum se poate vedea mai sus. Frunzele arborelui reprezintă domenii care nu au subdomenii, dar care conțin totuși sisteme. Un domeniu frunză poate conține de la un singur sistem gazdă până la mii de sisteme gazdă.
Domeniile de pe primul nivel se împart în două categorii: generice (gTLD-uri) și de țări (ccTLD-uri). Domeniile generice inițiale erau com (comercial), edu (instituții educaționale), gov (guvernul SUA), int (organizații internaționale), mil (forțele armate ale SUA) și org (organizații nonprofit). În ziua de astăzi, restricțiile legate de astfel de domenii sunt mult mai mici, existând astfel peste 1200 de domenii top-level generice. Domeniile de țări includ o intrare pentru fiecare țară, după cum se definește în ISO 3166. Fiecare domeniu este denumit de calea în arbore până la rădăcină. Componentele sunt separate prin punct. Astfel, departamentul de Calculatoare de la UPB poate fi cs.pub.ro în loc de numele în stil UNIX /ro/pub/cs.
Numele de domenii pot fi absolute sau relative. Un nume absolut de domeniu (FQDN - fully qualified domain name) este un nume de domeniu care nu permite nici o ambiguitate cu privire la locația relativă la rădăcina arborelui de nume de domenii. Astfel de nume absolute de domenii se termină cu punct (de exemplu cs.pub.ro.). În contrast, un nume relativ de domeniu este un nume care are sens numai relativ la un anume domeniu DNS (altul decât cel rădăcină).
Numele de domenii nu fac distincție între litere mici și litere mari, edu sau EDU însemnând practic același lucru. Componentele numelor pot avea o lungime de cel mult 64 de caractere, iar întreaga cale de nume nu trebuie să depășească 255 de caractere.
Fiecare domeniu controlează cum sunt alocate domeniile de sub el. De exemplu, Japonia are domeniile ac.jp și co.jp echivalente cu edu și com. Olanda nu face nicio distincție și pune toate organizațiile direct sub nl. Pentru a crea un nou domeniu, se cere permisiunea domeniului în care va fi inclus. De exemplu, dacă un grup PCom de la CS dorește să fie cunoscut ca pcom.cs.pub.ro, acesta are nevoie de permisiunea celui care administrează cs.pub.ro. Similar, o nouă universitate care dorește obținerea unui domeniu va trebui să ceară permisiunea administratorului domeniului edu. În acest mod, sunt evitate conflictele de nume și fiecare domeniu poate ține evidența tuturor subdomeniilor sale. Odată ce un nou domeniu a fost creat și înregistrat, el poate crea subdomenii, fără a cere permisiune de la cineva din partea superioară a arborelui.
Algoritmul de interogare
Conceptele cu care DNS lucrează sunt:
- Servere DNS - Stații care rulează programe de tip server de DNS ce conțin informații asupra bazelor de date DNS și despre structura numelor de domenii.
- Resolvere DNS - Programe care folosesc cereri DNS pentru interogarea unor servere DNS.
Modul în care se derulează procesul de interogare DNS este cel din figura de mai jos.
Înregistrări de resurse
Fiecărui domeniu, fie că este un singur calculator gazdă, fie un domeniu de nivel superior, îi poate fi asociată o mulțime de înregistrări de resurse (resource records sau RR-uri). Pentru un singur sistem gazdă, cea mai obișnuită înregistrare de resursă este chiar adresa IP, dar există multe alte tipuri.
Atunci când procedura resolver trimite un nume de domeniu DNS, ceea ce va primi ca răspuns sunt înregistrările de resurse asociate acelui nume. Astfel, adevărata funcție a DNS este să realizeze corespondența dintre numele de domenii și înregistrări de resurse.
O înregistrare de resursă este un 5-tuplu. Cu toate că, din rațiuni de eficiență, înregistrările de resurse sunt codificate binar, în majoritatea expunerilor ele sunt prezentate ca text ASCII, câte o înregistrare de resurse pe linie. Formatul utilizat este <Nume_domeniu, Timp_de_viață, Tip, Clasă, Valoare>:
- Câmpul Nume_domeniu precizează domeniul căruia i se aplică această înregistrare. În mod normal, există mai multe înregistrări pentru fiecare domeniu, și fiecare copie a bazei de date păstrează informații despre mai multe domenii. Acest câmp este utilizat cu rol de cheie de căutare primară pentru a satisface cererile. Ordinea înregistrărilor în baza de date nu este semnificativă. Când se face o interogare despre un domeniu, sunt returnate toate înregistrările care se potrivesc cu clasa cerută.
- Câmpul Timp_de_viață dă o indicație despre cât de stabilă este înregistrarea.
- Câmpul Tip precizează tipul înregistrării. Cele mai importante tipuri sunt prezentate în tabelul de mai jos.
| Tip | Semnificație | Valoare |
|---|---|---|
| SOA | Start autoritate | Parametri pentru această zonă |
| A | Adresa IPv4 a unui sistem gazdă | Întreg pe 32 de biți |
| AAAA | Adresa IPv6 a unui sistem gazdă | Întreg pe 128 de biți |
| MX | Schimb de poștă | Prioritate, domeniu dispus să accepte poștă electronică |
| NS | Server de nume | Numele serverului pentru acest domeniu |
| CNAME | Nume canonic | Numele domeniului |
| PTR | Pointer | Pseudonim pentru adresă IP |
| HINFO | Descriere sistem gazdă | Unitate centrală și sistem de operare în ASCII |
| TXT | Text | Text ASCII neinterpretat |
O înregistrare SOA furnizează numele sursei primare de informație despre zona serverului de nume, adresa de e-mail a administratorului, un identificator unic si diverși indicatori și contoare de timp.
Cel mai important tip de înregistrare este înregistrarea A (adresă). Ea păstrează adresa IP de 32 de biți a sistemului gazdă. Următoarea ca importanță este înregistrarea MX. Aceasta precizează numele domeniului pregătit să accepte poștă electronică pentru domeniul specificat. Înregistrările specifică numele serverului.
Un exemplu de informație ce se poate găsi în baza de date DNS a unui domeniu este următorul:
; Authoritative Information on physics.groucho.edu.
@ IN SOA niels.physics.groucho.edu.
janet.niels.physics.groucho.edu. {
1999090200 ; serial no
360000 ; refresh
3600 ; retry
3600000 ; expire
3600 ; default ttl
};
; Name servers
IN NS niels
IN NS gauss.maths.groucho.edu.
gauss.maths.groucho.edu. IN A 149.76.4.23
;
; Theoretical Physics (subnet 12)
niels IN A 149.76.12.1
IN A 149.76.1.12
name server IN CNAME niels
otto IN A 149.76.12.2
quark IN A 149.76.12.4
down IN A 149.76.12.5
strange IN A 149.76.12.6
...
; Collider Lab. (subnet 14)
boson IN A 149.76.14.1
muon IN A 149.76.14.7
bogon IN A 149.76.14.12
...
Servere de nume
Teoretic, un singur server de nume poate conține întreaga bază de date DNS și poate să răspundă tuturor cererilor. În practică, acest server poate fi atât de încărcat încât să devina de neutilizat. Pentru a evita probleme asociate cu existența unei singure surse de informație, spațiul de nume DNS este împărțit în zone care nu se suprapun. O posibilă astfel de împărțire este cea de mai jos.
Fiecare astfel de zonă conține câte o parte a arborelui, precum și numele serverelor care păstrează informația autoritară despre acea zonă. În mod normal, o zonă va avea un server de nume primar, care preia informația dintr-un fișier de pe discul propriu, și unul sau mai multe servere de nume secundare, care iau informația de la serverul primar. Pentru a îmbunătăți fiabilitatea, unele servere pentru o zonă pot fi plasate chiar în afara zonei.
Plasarea limitelor unei zone este la latitudinea administratorului ei. Această decizie este luată în mare parte pe baza numărului de servere de nume care se doresc a se folosi, și a locației acestora. Atunci când un resolver are o cerere referitoare la un nume de domeniu, el transferă cererea unuia din serverele locale de nume. Dacă domeniul este sub jurisdicția serverului de nume, el va întoarce înregistrări de resurse autoritare. O înregistrare autoritara (authoritative record) este cea care vine de la autoritatea care administrează înregistrarea, și astfel este întotdeauna corectă. Înregistrările autoritare se deosebesc de înregistrările din memoria cache, care pot fi expirate.
Dacă totuși domeniul se află la distanță, iar local nu este disponibilă nici o informație despre el, atunci serverul de nume trimite un mesaj de cerere către serverul de nume de pe primul nivel al domeniului solicitat. De menționat că metoda de interogare este recursivă (recursive query), deoarece fiecare server care nu are informația cerută o caută în altă parte și raportează.
API DNS
gethostbyname() și gethostbyaddr()
Până de curând, pentru a afla un nume pe baza unei adrese IP și o adresă pe baza unui nume, se foloseau funcțiile gethostbyname() și gethostbyaddr(), împreună cu structura hostent. Între timp, acest API pentru DNS a fost scos din uz.
getaddrinfo()
#include <sys/types.h>
#include <sys/socket.h>
#include <netdb.h>
int getaddrinfo(const char *node, const char *service,
const struct addrinfo *hints, struct addrinfo **res);
Funcția getaddrinfo() primește informații despre numele unei gazde și al unui serviciu Internet, și returnează adresa sau adresele corespunzătoare. Parametrul node reprezintă numele simbolic (sub forma unui șir de caractere) al mașinii căreia vrem sa-i aflăm adresa (de exemplu, node poate fi “www.google.com”). Mai poate de asemenea fi reprezentat ca un șir care conține o adresă IPv4 sau IPv6.
Parametrul service specifică portul returnat în output, și poate fi pus pe NULL (caz în care portul din output rămâne neinițializat) sau poate fi dat ca un nume de serviciu (de exemplu, “http”) sau ca o valoare numerică (“80”).
Parametrul hints reprezintă criterii pentru filtrarea adreselor întoarse de apelul funcției getaddrinfo(). Este de tipul struct addrinfo, definit mai jos:
struct addrinfo {
int ai_flags;
int ai_family;
int ai_socktype;
int ai_protocol;
socklen_t ai_addrlen;
struct sockaddr *ai_addr;
char *ai_canonname;
struct addrinfo *ai_next;
};
În cazul în care se dorește filtrarea, se pot completa unul sau mai multe din următoarele câmpuri (restul punându-se pe 0):
- ai_family - se specifică familia de adrese pentru valorile returnate, putând fi setată ca AF_INET (pentru IPv4), AF_INET6 (pentru IPv6) sau AF_UNSPEC (pentru ambele)
- ai_socktype - se filtrează după tipul de socket (SOCK_DGRAM sau SOCK_STREAM, de exemplu)
- ai_protocol - se specifică protocolul setat în adresele returnate de funcția getaddrinfo()
- ai_flags - se pot seta o serie de flag-uri.
În final, rezultatul este pus în parametrul res, fiind reprezentat ca o listă înlănțuită de structuri de tipul addrinfo, care se parcurge prin intermediul câmpului ai_next. Din câmpul ai_addr al rezultatului, se pot citi informațiile despre adresa și portul stației gazdă căutate (prin cast la struct sockaddr_in, de exemplu).
În caz de succes, funcția întoarce 0, iar în caz de eroare întoarce o valoare negativă, care poate fi interpretată prin intermediul funcției gai_strerror():
const char *gai_strerror(int errcode);
Important
Parametrul res este alocat de către funcția getaddrinfo(), însă el trebuie dezalocat explicit de către utilizator prin intermediul funcției freeaddrinfo():
void freeaddrinfo(struct addrinfo *res);
Pentru a afișa adresa IP (v4 sau v6) corespunzătoare numelui simbolic din parametrul node, se poate utiliza funcția inet_ntop():
#include <arpa/inet.h>
const char *inet_ntop(int af, const void *src, char *dst, socklen_t size);
Primul parametrul specifică familia de protocoale (AF_INET sau AF_INET6), al doilea parametru reprezintă structura de adresă (adică, de exemplu, câmpurile sin_addr sau sin6_addr din structurile sockaddr_in pentru IPv4 sau sockaddr_in6 pentru IPv6), al treilea parametru reprezintă un șir de caractere unde va fi scrisă adresa sub formă de string, iar ultimul parametru reprezintă dimensiunea șirului de caractere în octeți. Valoarea de retur a funcției este un pointer la un șir de caractere identic cu cel din parametrul dst, sau NULL în caz de eroare.
getnameinfo()
#include <sys/socket.h>
#include <netdb.h>
int getnameinfo(const struct sockaddr *addr, socklen_t addrlen, char *host,
socklen_t hostlen, char *serv, socklen_t servlen, int flags);
Funcția getnameinfo() realizează operația inversă față de getaddrinfo(). Mai precis, primește o adresă și returnează numele simbolic și serviciul specifice adresei respective. Primii doi parametri reprezintă adresa IP (v4 sau v6) care este căutată. Se trimit structuri specifice protocolului dorit (sockaddr_in sau sockaddr_in6) și dimensiunea lor. Rezultatele apelului sunt puse în șirurile de caractere host și serv, care sunt alocate de către utilizator. Parametrii hostlen și servlen reprezintă dimensiunile celor două șiruri de caractere.
Funcția returnează 0 dacă s-a reușit cererea DNS, sau o valoare negativă interpretată cu gai_strerror() în caz contrar.
Cereri DNS în terminalul Linux
În Linux, pentru a obține adresele IP ale unei gazde, putem folosi unul din utilitarele host sau nslookup:
$ host google.com
google.com has address 172.217.20.14
google.com has IPv6 address 2a00:1450:400d:804::200e
google.com mail is handled by 30 alt2.aspmx.l.google.com.
google.com mail is handled by 50 alt4.aspmx.l.google.com.
google.com mail is handled by 20 alt1.aspmx.l.google.com.
google.com mail is handled by 10 aspmx.l.google.com.
google.com mail is handled by 40 alt3.aspmx.l.google.com.
$ nslookup google.com
Server: 192.168.100.1
Address: 192.168.100.1#53
Non-authoritative answer:
Name: google.com
Address: 172.217.20.14
Pentru aflarea unui nume pe baza unei adrese IP, se pot folosi tot host sau nslookup:
$ host 8.8.8.8
8.8.8.8.in-addr.arpa domain name pointer dns.google.
$ nslookup 8.8.8.8
8.8.8.8.in-addr.arpa name = dns.google.
Authoritative answers can be found from:
in-addr.arpa nameserver = a.in-addr-servers.arpa.
in-addr.arpa nameserver = b.in-addr-servers.arpa.
in-addr.arpa nameserver = c.in-addr-servers.arpa.
in-addr.arpa nameserver = e.in-addr-servers.arpa.
in-addr.arpa nameserver = d.in-addr-servers.arpa.
in-addr.arpa nameserver = f.in-addr-servers.arpa.
Un exemplu de output tcpdump pentru comanda host este mai jos (unde 53 este portul implicit pentru DNS):
$ sudo tcpdump port 53
tcpdump: data link type PKTAP
tcpdump: verbose output suppressed, use -v or -vv for full protocol decode
listening on pktap, link-type PKTAP (Apple DLT_PKTAP), capture size 262144 bytes
08:12:17.446368 IP 192.168.100.4.63991 > 192.168.100.1.domain: 31461+ A? google.com. (28)
08:12:17.450412 IP 192.168.100.1.domain > 192.168.100.4.63991: 31461 1/0/0 A 172.217.16.110 (44)
08:12:18.455598 IP 192.168.100.4.58146 > 192.168.100.1.domain: 4039+ AAAA? google.com. (28)
08:12:18.463761 IP 192.168.100.1.domain > 192.168.100.4.58146: 4039 1/0/0 AAAA 2a00:1450:400d:803::200e (56)
08:12:19.467330 IP 192.168.100.4.53016 > 192.168.100.1.domain: 58519+ MX? google.com. (28)
08:12:19.514778 IP 192.168.100.1.domain > 192.168.100.4.53016: 58519 5/0/0 MX alt3.aspmx.l.google.com. 40,
MX alt4.aspmx.l.google.com. 50, MX aspmx.l.google.com. 10, MX alt1.aspmx.l.google.com. 20,
MX alt2.aspmx.l.google.com. 30 (136)
Utilitarul dig
dig (Domain Information Groper) este un utilitar Linux care interoghează servere de nume și afișează rezultatele într-o varietate de forme.
Înainte a vedea cum funcționează dig, este util să observăm formatul unui pachet DNS, prezentat mai jos (formatul fiecărei secțiuni se găsește detaliat în RFC 1035):
+---------------------+
| Antet |
+---------------------+
| Întrebare | întrebarea pentru serverul de nume
+---------------------+
| Răspuns | RR-uri care răspund la întrebare
+---------------------+
| Autoritate | RR-uri care indică o autoritate
+---------------------+
| Adițional | RR-uri care conțin informație adițională
+---------------------+
Pentru a interoga o singură gazdă, comanda de dig arată în felul următor:
$ dig google.com
Output-ul obținut se mapează pe structura unui răspuns standard DNS, cu mențiunea că unele din RR-uri (de exemplu, cele adiționale sau de autoritate) pot să lipsească. Prima parte a output-ului conține informații despre versiunea de dig folosită și opțiunile alese:
; <<>> DiG 9.11.3-1ubuntu1.7-Ubuntu <<>> google.com
;; global options: +cmd
Mai departe, urmează desfășurarea răspunsului primit de la server-ul DNS, începând cu antetul:
;; Got answer:
;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 35678
;; flags: qr rd ra; QUERY: 1, ANSWER: 1, AUTHORITY: 13, ADDITIONAL: 1
În continuare, se afișează partea de întrebare (în exemplul nostru, pentru o adresă IPv4, adică A):
;; QUESTION SECTION:
;google.com. IN A
După întrebare, urmează RR-urile de răspuns (în cazul nostru, este unul singur, adică adresa IP a gazdei date):
;; ANSWER SECTION:
google.com. 300 IN A 172.217.16.142
Mai departe, secțiunea cu RR-uri de autoritate ne spune ce servere DNS pot să ne ofere răspunsuri autoritare la cererile noastre:
;; AUTHORITY SECTION:
. 214056 IN NS c.root-servers.net.
. 214056 IN NS b.root-servers.net.
. 214056 IN NS a.root-servers.net.
. 214056 IN NS d.root-servers.net.
În continuare, ar putea urma secțiunea de RR-uri adiționale (care ar putea include, de exemplu, adresele serverelor de nume autoritare din secțiunea precedentă), iar la final ni se oferă statistici despre cerere:
;; Query time: 37 msec
;; SERVER: 172.16.10.254#53(172.16.10.254)
;; WHEN: Thu Apr 09 07:32:54 EEST 2020
;; MSG SIZE rcvd: 266
Așa cum se poate observa și mai sus, o cerere implicită de dig este realizată cu tipul A (adresă IPv4). Dacă se doresc altfel de cereri, acest lucru se poate specifica la rulare, imediat după numele gazdei. Pe lângă înregistrările propriu-zise, putem folosi și wildcard-ul ANY, care ne interoghează după toate tipurile de RR-uri:
$ dig ANY google.com
[...]
;; ANSWER SECTION:
google.com. 283 IN AAAA 2a00:1450:400d:805::200e
google.com. 39 IN SOA ns1.google.com. dns-admin.google.com. 305440781 900 900 1800 60
google.com. 25 IN A 172.217.20.14
google.com. 26083 IN NS ns3.google.com.
[...]
Dacă nu ne interesează tot output-ul de mai sus și vrem să afișăm doar adresa IP (v4 sau v6), putem folosi opțiunea +short:
$ dig A google.com +short
172.217.20.14
$ dig AAAA google.com +short
2a00:1450:400d:803::200e
Dacă dorim să nu afișăm vreuna din secțiunile de răspuns ale dig, putem alege din opțiunile de mai jos:
- +nocomments – nu se afișează liniile de comentarii
- +noauthority – nu se afișează secțiunea de RR-uri autoritare
- +noadditional – nu se afișează secțiunea de RR-uri adiționale
- +nostats – nu se afișează secțiunea de statistici
- +noanswer – nu se afișează secțiunea de răspuns.
Dacă dorim, putem dezactiva afișarea tuturor secțiunilor (cu opțiunea +noall) și apoi să alegem ce vrem să afișăm. Astfel, cele două comenzi de mai jos sunt echivalente, afișând doar secțiunea de răspuns:
$ dig google.com +nocomments +noquestion +noauthority +noadditional +nostats
; <<>> DiG 9.10.6 <<>> google.com +nocomments +noquestion +noauthority +noadditional +nostats
;; global options: +cmd
google.com. 56 IN A 216.58.214.238
$ dig google.com +noall +answer
; <<>> DiG 9.10.6 <<>> google.com +noall +answer
;; global options: +cmd
google.com. 43 IN A 216.58.214.23
În mod implicit, dig folosește serverele de nume din fișierul /etc/resolv.conf. Totuși, dacă dorim, putem specifica serverul de nume pe care îl interogăm în felul următor:
$ dig @8.8.8.8 google.com
[...]
$ dig @ns1.google.com google.com
[...]
Dacă se dorește interogarea unui server DNS pentru un număr mai mare de gazde (o interogare de tip bulk), acest lucru se poate face prin adăugarea lor într-un fișier și folosirea opțiunii -f:
$ cat queries.txt
google.com
facebook.com
twitter.com
$ dig -f queries.txt +noall +answer
google.com. 106 IN A 172.217.19.110
facebook.com. 43 IN A 185.60.218.35
twitter.com. 1052 IN A 104.244.42.193
twitter.com. 1052 IN A 104.244.42.1
Așa cum s-a menționat și mai sus, sistemul DNS este organizat ierarhic, deci o cerere dig parcurge mai multe servere DNS. Acest lucru se poate observa prin intermediul parametrului +trace:
$ dig google.com +noall +answer +trace
Pentru a realiza o căutare inversă (reverse lookup), se folosește opțiunea -x pentru a obține domeniul și numele asociate cu un IP:
$ dig -x 8.8.8.8 +noall +answer
; <<>> DiG 9.10.6 <<>> -x 8.8.8.8 +noall +answer
;; global options: +cmd
8.8.8.8.in-addr.arpa. 17648 IN PTR dns.google.
Un mesaj e-mail a fost întotdeauna transmis în format plain-text (text clar). Chiar si prin adăugarea atașamentelor, mesajele de e-mail sunt trimise tot ca mesaje plain-text, prin folosirea unor mecanisme de codificare (uuencode/uudecode, MIME/BASE64).
Un mesaj este format dintr-o secțiune de antete (headers), urmată de o secțiune cu conținutul mesajului. Structura antetelor este descrisă în RFC 822, RFC 1521 și RFC 1806, ele având în general următoarea structură:
- unul sau mai multe antete Received, care indică ce cale a fost urmată de mesaj de la sursa până la destinație
- Mime-Version: versiunea MIME (Multipurpose Internet Mail Extensions) folosită, 1.0 in general
- Content-Type: text/plain pentru mesaje text, multipart/mixed pentru mesaje cu atașamente
- Subject: subiectul mesajului
- Date: data și ora când a fost trimis mesajul
- Message ID: un ID pentru mesaj, folosit pentru identificarea în mod unic a unui mesaj
- From: numele și adresa de mail a expeditorului
- To: numele și adresa de mail a destinatarului
- Cc: carbon copy (alți destinatari)
- alte antete introduse de clientul de e-mail folosit pentru a trimite mesajul.
Conținutul mesajului este textul propriu-zis, pentru mesajele în text clar fără atașamente. Se poate observa mai jos un exemplu de mesaj:
MIME-Version: 1.0
From: profesor@upb.ro
To: student@upb.ro
Subject: Tema
Content-Type: text/plain
Draga student,
Fa-ti tema!
Cu bine,
Profesorul.
Mesajele cu atașamente pot folosi una din următoarele tehnici pentru codificarea acestora:
- uuencode - la începuturile e-mail-ului, fișierele care se doreau trimise trebuiau convertite în format text și invers prin folosirea utilitarelor numite uuencode/uudecode; și în ziua de azi, unii clienți de mail adaugă atașamentele la sfârșitul mesajelor, codificându-le cu algoritmul folosit de uuencode
- MIME/Base64 - această tehnologie este cea recomandată pentru trimiterea de mesaje cu atașamente.
Un mesaj cu atașamente codificate MIME arată în felul următor:
MIME-Version: 1.0
From: Student Studentescu <student@upb.ro>
To: Profesor PC <profesor@upb.ro>
Subject: Re: Tema
Content-Type: multipart/mixed; boundary=abc
--abc
Content-Type: text/plain
Atasez tema.
Cu bine,
Studentul
--abc
Content-Type: text/plain
Content-Disposition: attachment; filename="tema.c"
#include <stdio.h>
int main()
{
printf("Aceasta este tema mea\n");
return 0;
}
--abc
Se observă faptul că părțile care compun mesajul sunt separate între ele printr-un șir de caractere separator (boundary string), specificat ca un parametru pentru antetul Content-Type. Fiecare parte poate avea la rândul ei propriile antete, care conțin în general tipul și numele fișierului din secțiunea respectivă. În cazul în care se trimit atașamente binare, acestea sunt codificate folosind schema numită Base64, descrisă în RFC 1521.
Protocoalele SMTP, POP3 și IMAP
În terminologia folosită de sistemele de e-mail, există trei actori. Aceștia pot fi situați pe trei mașini diferite sau pot co-exista pe aceeași gazdă:
- Mail User Agent (MUA) - aplicația folosită de utilizator pentru a citi și trimite mesaje e-mail (clientul de e-mail); el nu primește direct mesaje, acesta fiind rolul Mailbox Server-ului
- Mailbox Server - serverul care primește și stochează mesajele (server de e-mail)
- Mail Transfer Agent (MTA) - aplicația care primește și retrimite mesajele spre un alt MTA sau spre un Mailbox Server (“router” de e-mail).
Protocolul folosit pentru MTA este SMTP, iar cele mai folosite protocoale pentru interacțiunea cu mailbox-urile sunt POP3 și IMAP.
SMTP
SMTP (Simple Mail Transfer Protocol) este un protocol care se folosește pentru trimiterea mesajelor electronice (de la un client către un server). Acesta se foloseste de portul 25 peste TCP și este descris în RFC 821 și RFC 5321.
Mesajele necesare în SMTP pentru trimiterea unui e-mail sunt următoarele:
1: HELO client.upb.ro
2: MAIL FROM: <profesor@upb.ro>
3: RCPT TO: <student@upb.ro>
4: DATA
5: MIME-Version: 1.0
From: profesor@upb.ro
To: student@upb.ro
Subject: Tema
Content-Type: text/plain
Draga student,
Fa-ti tema!
Cu bine,
Profesorul.
.
6: QUIT
Se trimite deci întâi o comandă "HELO" cu numele de domeniu sau adresa
IP a clientului pentru a iniția sesiunea, apoi o comandă "MAIL FROM" cu
adresa sursei, "RCPT TO" pentru destinație, "DATA" pentru date
(e-mail-ul în sine) și "QUIT" pentru a se închide sesiunea. Secțiunea de
date trebuie neapărat terminată cu secvența de caractere
<CR><LF>.<CR><LF> (adică o linie nouă urmată de un punct și apoi de
încă o linie nouă).
POP3
POP3 (Post Office Protocol 3) este un protocol utilizat pentru citirea mesajelor electronice (de la un server către un client). Clientul va interoga periodic serverul, va descărca mesajele și le va șterge automat de pe server. Comunicația se realizează folosind portul 110 peste TCP, în felul următor:
1: USERNAME username
2: PASS password
3: LIST
4: RETR 1
5: QUIT
IMAP
IMAP (Internet Message Access Protocol) este un protocol care se folosește pentru citirea mesajelor electronice (de la un server catre un client). Clientul interoghează periodic serverul și poate cere mesaje complete sau doar porțiuni (header, body), și nu va șterge automat mesajele de pe server. Comunicația se realizează prin TCP, folosind portul 143.
1: LOGIN username password
2: LIST "" "*"
3: EXAMINE Inbox
4: FETCH 1 BODY[]
5: LOGOUT
Exerciții
La acest laborator, vom avea 2 seturi de exerciții:
Exerciții DNS
Pornind de la codul disponibil aici, implementați următoatrea cerință:
1. Scrieți un program care să afișeze numele și adresele IP pentru un host. Programul poate primi ca parametru fie numele (caz în care se va afișa adresa), fie adresa IP (caz în care se va afișa numele).
Exemple de apel:
./dns -n google.com
./dns -a 8.8.8.8
Testați-va programul comparând rezultatele cu cele oferite de nslookup sau host.
2. Folosind utilitarul dig, realizați următoarele sarcini:
- realizați cereri de adresă (A) pentru fiecare gazdă din tabelul de mai jos
- pentru a afla serverul de mail pentru domeniul din tabel, realizați o cerere de tip MX
| Tip | Gazdă | Răspuns | TTL | Prioritate |
|---|---|---|---|---|
| Pentru single-v4 există o singură adresă IPv4 | ||||
| A | single-v4.protocoale.life | 127.0.0.1 | 300 | |
| Pentru single-v4 există o singură adresă IPv6 | ||||
| AAAA | single-v6.protocoale.life | ::1 | 300 | |
| Pentru single se definesc 2 adrese (una IPv4 si una IPv6) | ||||
| A | single.protocoale.life | 127.0.0.1 | 300 | |
| AAAA | single.protocoale.life | ::1 | 300 | |
| Spațiul dorinel.protocoale.life este delegat către un alt server de nume ce rulează la adresa potato.dfilip.xyz | ||||
| NS | dorinel.protocoale.life | potato.dfilip.xyz | 300 | |
| Pentru multi-v4 există 4 adrese IPv4 | ||||
| A | multi-v4.protocoale.life | 127.1.1.1 | 300 | |
| A | multi-v4.protocoale.life | 127.2.2.2 | 300 | |
| A | multi-v4.protocoale.life | 127.3.3.3 | 300 | |
| A | multi-v4.protocoale.life | 127.4.4.4 | 300 | |
| Pentru multi-v6 există 4 adrese IPv6 | ||||
| AAAA | multi-v6.protocoale.life | ::1 | 300 | |
| AAAA | multi-v6.protocoale.life | ::2 | 300 | |
| AAAA | multi-v6.protocoale.life | ::3 | 300 | |
| AAAA | multi-v6.protocoale.life | ::4 | 300 | |
| Pentru multi se definesc 8 adrese (4 de IPv4 și 4 de IPv6) | ||||
| A | multi.protocoale.life | 127.1.1.1 | 300 | |
| A | multi.protocoale.life | 127.2.2.2 | 300 | |
| A | multi.protocoale.life | 127.3.3.3 | 300 | |
| A | multi.protocoale.life | 127.4.4.4 | 300 | |
| AAAA | multi.protocoale.life | ::1 | 300 | |
| AAAA | multi.protocoale.life | ::2 | 300 | |
| AAAA | multi.protocoale.life | ::3 | 300 | |
| AAAA | multi.protocoale.life | ::4 | 300 | |
| Adresele pc→pcom→protocoale definesc un șir de nume canonice care are la capăt o adresă IPv4 | ||||
| CNAME | pc.protocoale.life | pcom.protocoale.life | 300 | |
| CNAME | pcom.protocoale.life | protocoale.protocoale.life | 300 | |
| A | protocoale.protocoale.life | 127.42.42.42 | 300 | |
| Emailul este deservit de 3 servere SMTP cu priorități diferite | ||||
| MX | protocoale.life | alt1.gmail-smtp-in.l.google.com | 300 | 10 |
| MX | protocoale.life | alt2.gmail-smtp-in.l.google.com | 300 | 20 |
| MX | protocoale.life | alt3.gmail-smtp-in.l.google.com | 300 | 30 |
| Tip | Gazdă | Răspuns | TTL | Prioritate |
Exerciții E-mail
Suport laborator
Vă oferim aici un cod sursă schelet pentru realizarea unui client de email SMTP scris in C.
Pentru testarea lui, veți folosi un server SMTP acre rulează local. Acesta poate fi creat folosind un utilitar existent în Python, numit smtpd. Rularea acestui utiliar pe portul 25 se face astfel:
sudo python -m smtpd -n -c DebuggingServer 127.0.0.1:25
Atenție! Pentru a putea rula serverul smtpd pe un port mai mic de 1024, trebuie să aveți drepturi de root, deci vom rula cu sudo.
Cerințe
Pornind de la codul disponibil aici, implementați următoatrele cerințe:
- Implementați un client SMTP peste TCP prin care să trimiteți către serverul smtpd un e-mail care conține niște text și un fișier dat ca parametru sub forma unui atașament de tip text/plain.
- (Extra) Folosind instrucțiunile de aici, trimiteți un e-mail către asistent prin intermediul serverului SMTP de la Google.
Lectură laborator
De parcurs înainte de laborator:
Primitive criptografice
Pentru a adresa posibilele amenințări ce vizează comunicarea, vom avea nevoie de protocoale de securitate, precum TLS, sau SSH. Acestea au la bază niște primitive criptografice, care ajută la îndeplinirea unor obiective concrete: confidențialitate, integritate, autenticitate etc.
Criptografia în sine este un subiect foarte stufos, și o introducere sumară în criptografie ar alcătui materialul pentru un curs de un singur semestru. Din motive de timp, noi vom parcurge foarte sumar niște concepte de bază, concentrându-ne pe cum le putem folosi, ca utilizatori, pentru a dezvolta protocoale rezistente la atacuri. Pentru cei interesați de cum funcționează aceste lucruri, de teoria din spate, de disciplina practică etc., vă recomandăm:
- Cursul "Cryptography I" predat de Dan Boneh în cadrul Universității din Stand, California, disponibil pe Coursera.
- Cartea "Applied Cryptography" a lui Bruce Schneier, disponibilă pe "The Internet Archive".
Principiul lui Kerckhoffs
"Principiul lui Kerckhoffs" este o propoziție simplă care stă la baza criptografiei moderne:
Un sistem criptografic ar trebui să fie sigur, chiar dacă toate amănuntele legate de designul și implementarea lui sunt cunoscute, cu excepția unei cantități mici de date, numite "cheie".
Criptare Simetrică
Criptarea simetrică implică folosirea unei singure chei atât pentru criptarea cât și pentru decriptarea datelor. Astfel, pentru orice mesaj M și pentru orice cheie K, trebuie să se respecte următoarea egalitate:
D(E(M, K), K) = M
Presupunem că Alice și Bob au aceeași cheie secretă K, iar Alice vrea să-i trimită mesajul M lui Bob:
- Alice folosește algoritmul de encriptie pentru a obține un cifru C = E(M, K)
- Alice trimite C către Bob. Oricine interceptează acest mesaj, nu poate obține mesajul original M
- Bob primește C și aplică algoritmul de decriptie pentru a recupera mesajul original M = D(C, K)
Exemple de algoritmi de criptare simetrică:
AES, DES, 3DES, IDEA, Blowfish
Notă
În cazul criptarii simetrice dificultatea provine din necesitatea existenței unui mecanism de securitate pentru distribuirea cheii secrete. În plus, nu este fezabil ca orice pereche de entități care doresc să comunice criptat să împărtășească un secret (e.g. google ar trebui să aibă câte o cheie pentru fiecare client).
Există protocoale de schimbare de chei (key exchange), astfel încât două entități să poată alege împreună o cheie privată peste un canal de comunicare nesigur, astfel încât nimeni altcineva care poate vedea informația transmisă să nu poată determina cheia (și, deci, nici mesajele ulterioare, criptate cu cheia aleasă). Cel mai cunoscut astfel de protocol este DiffieHellman. Acesta este totuși nesigur în cazul atacurilor de tipul Man-in-the-Middle când atacatorul poate intercepta mesaje și produce propriile mesaje.
Criptare Asimetrică
Criptarea asimetrică presupune folosirea unei perechi de chei: una pentru encriptie, cealaltă pentru decriptie. Ambii participanți la trafic au câte o pereche de chei. Cheia de encriptie este o cheie publică, absolut oricine o poate cunoaște și o poate folosi pentru a encripta un mesaj. Cheie de decriptie este o cheie privată (secretă), cunoscută doar de proprietarul ei, acesta putând să o folosească pentru a decripta mesaje encriptate cu cheia să publică.
Astfel, pentru orice pereche de chei (P, S) și orice mesaj M, trebuie să se respecte următoarea egalitate:
- D(E(M, P), S) = M
În unele sisteme de criptare asimetrică, este posibilă și encriptarea cu cheie secretă, decriptarea cu cheie publică:
- D(E(M, S), P) = M
Presupunem că Alice vrea să-i trimită lui Bob mesajul M:
- Alice obține cheia publică PB a lui Bob (e.g. o cere explicit printr-un mesaj)
- Alice calculează un cifru C = E(M, PB)
- Alice trimite C către Bob.
- Bob primește C și aplică algoritmul de decriptie pe cifrul primit și pe cheia să secretă pentru a recupera mesajul original M = D(C, SB)
Deoarece Bob e singură persoană care cunoaște SB, este singurul care poate decripta C. Nici măcar Alice nu mai poate recupera mesajul original din C. Similar, dacă Bob dorește să trimită un răspuns, el trebuie să obțînă cheia publică a lui Alice.
Un algoritm de criptare asimetrică larg utilizat este RSA.
Notă:
Pentru sistemele de criptare asimetrică, există problema: cum putem fi siguri că cheia publică primită chiar aparține cui credem că aparține? (un atacator ar putea folosi un atac Man-in-the-Middle pentru a ne livra propria să cheie).
Rezumate de mesaje
Rezumatul (hash) unui mesaj este un șir de biți de lungime fixă, generat cu ajutorul unei funcții de dispersie neinversabile aplicată mesajului. Funcția de dispersie H trebuie să aibă următoarele proprietăți:
- Dându-se un mesaj M, este ușor de calculat H(M)
- Dându-se un rezumat H(M), este greu de calculat M
- Dându-se un mesaj M, este greu de găsit M0 astfel încât H(M) = H(M0)
- O schimbare mică în mesaj (chiar și de 1 bit) produce un rezumat foarte diferit
Rezumatele pot fi utilizate pentru a verifică rapid transmisia corectă a unui mesaj (rezumatul este transmis împreună cu mesajul și destinatarul verifică dacă rezumatul primit coincide cu rezumatul recalculat de către el) și pentru realizarea semnăturilor digitale.
Exemple de algoritmi pentru calculul de rezumate: MD5, SHA-1, SHA-2, SHA-3
Semnături digitale
Semnăturile digitale asigură autenticitatea mesajelor, verificarea semnăturilor oferind garanțiile:
- Autentificare - mesajul provine de la sursă pretinsă și nu a fost falsificat de altcineva
- Integritate - mesajul nu a fost alterat de altcineva, ci este așa cum a fost scris de sursă
- Non-repudiere - entitatea care a semnat mesajul nu poate nega ulterior semnarea acestuia
Pentru un sistem foarte simplu de semnătură digitală, să considerăm exemplul în care Alice dorește să-i trimitaa lui Bob un mesaj M, folosindu-se de un sistem de criptare asimetrică și o funcție de hashing.
- Alice calculează rezumatul mesajului R = H(M)
- Alice criptează acest rezumat cu cheia secretă obținând un cifru C = E(R, SA)
- Alice trimite perechea (M, C) către Bob
- Bob obține cheia publică PĂ a lui Alice
- Bob primește perechea (M, C)
- Bob calculează propriul rezumat R = H(M)
- Bob decripteaza C folosind cheia publică R0 = D(C, PA)
- Dacă R = R0, Bob are garanția că mesajul a fost semnat de Alice și nimeni nu l-a modificat
Entități de încredere
Pentru că două entități să poată comunica sigur utilizând sistemul de criptare asimetrică, fiecare trebuie să cunoască cheia publică a celeilalte și să nu existe riscul că un intrus să substituie o cheia publică cu propria să cheie publică. Una dintre soluțiile utilizate la ora actuală pentru această problema este certificarea cheilor de către organizații speciale numite autorități de certificare (Certification Authority - CA).
O entitate care dorește un certificat trebuie să se adreseze unei CA, autentificandu-se și furnizând cheia să publică; autoritatea de certificare poate decide să acorde persoanei certificatul, care va conține identitatea și cheia publică a solicitantului. Certificatul este semnat digital de către autoritatea de certificare. Formatul utilizat de obicei pentru certificate este X.509.
Autoritățile de certificare sunt organizate ierarhic, existând o serie de CA-uri "rădăcina" care sunt bine cunoscute, altă serie de CA-uri certificate de CA-urile rădăcina s.a.m.d. În momentul în care este verificat certificatul unei entități se verifică și autoritatea de certificare CA1 care l-a emis, și care are și ea un certificat de la o altă autoritate CA2; apoi se verifică CA2 și așa mai departe până se ajunge la o CA în care se poate avea iıncredere sau la o CA rădăcina (astfel se formează un "lanț de încredere" sau o "cale de certificare"). CA-urile rădăcina au certificate auto-semnate.
Informații despre baza lanțului de încredere sunt incluse în aplicații (mail client, web browser etc.), sau în sistemul de operare, care servește aplicațiile interesate.
Exerciții
Scheletul laboratorului se regaseste la adresa de aici. Funcționalitatea disponibilă este descrisă în README.md. În continuare, vom folosi Mininet pentru a simula o topologie formată din 3 host-uri conectate la un router: sudo python3 topo.py.
Pentru a observa anumite probleme și soluții legate de securitate, vom avea în vedere următorul scenariu:
- Bob oferă un serviciu de capitalizare a datelor; un client se poate conecta la
severul său, să îi trimită pe rând șiruri de date și să primească varianta
capitalizată a inputului. (
server.c) - Alice vrea să folosească serviciul lui Bob. (
client.c) - Comunicarea dintre cei doi este intermediată de un router compromis, sub
controlul unui atacator. (
attacker.c)
Găsiți în scheletul de laborator o topologie topo.py care descrie o topologie
cu un singur router compromis, situat între Alice și Bob.
Rulați make și rulați topologia (sudo python3 topo.py):
- rulați
./attackerîn terminalul corespunzător routerului (numit "r") (rulați./attacker 2>/dev/nullpentru a elimina outputul de logging) - rulați
make run_serverpe terminalulbob. - rulați
make run_clientpe terminalulalice. - din terminalul lui Alice, scrieți mesaje din linia de comandă; acestea vor circula către server, prin routerul compromis; serverul le va prelucra și va transmite înapoi răspunsul.
- observați cum atacatorul poate vedea tot ce comunică Alice și Bob
1. Comunicare criptată. Pentru ca atacatorul să nu mai poată vedea ce
comunică Alice și Bob între ei, modificați atât clientul cât și serverul astfel
încât toate datele să fie criptate.
Pentru ca Alice și Bob să aibă aceeași cheie, aceasta va fi generată de Alice și
transmisă în primul mesaj către Bob. Vim folosi urmatorul API din tea.h. Vom
face lucra in client.c si server.c.
/* Cripteaza un mesaj cu cheia k */
uint8_t *encrypt(uint8_t *plaintext, uint32_t *size, const uint32_t *k);
/* Decripteaza un mesaj cu cheia k */
uint8_t *decrypt(uint8_t *cipher, uint32_t *size, const uint32_t *k);
/* Creeaza o cheie secreta k. Dimensiunea cheii este sizeof(uint32_t) * 4 */
uint32_t *create_key();
2. Interceptare cheie. La exercițiul anterior am criptat toate mesajele
dintre Alice și Bob; dar cheia de criptare a fost transmisă prin același canal de
comunicare compromis. Modificați codul atacatorului astfel încât să intercepteze
cheia transmisă și apoi să decripteze fiecare mesaj. Pentru acest exercitiu
vom lucra in attacker.c.
3. Diffie-Hellmann key-exchange. Implementați mecanismul Diffie-Hellmann de
schimbare de cheie peste un canal nesecurizat. Astfel Alice și Bob vor ajunge să
aibă aceeași cheie de criptare, fără a fi nevoiți să o transfere explicit peste
canalul compromis. Vom folosi urmatorul API din dh.h.
/* Calculeaza g^a mod p */
uint32_t mod_pow(uint32_t g, uint32_t a, uint32_t p);
/* Generates un secret, secret DH, pe baza numaruilui prim public p.
Acesta este valoarea a in cazul lui Alice si b in cazul lui Bob.*/
uint32_t generate_secret(uint32_t p);
/* Takes a shared secret and generates an encryption key. The difference between
* the two is as follow. Generate_secret returns a for alice. They
* compute g^(ab) mod p. This is the shared secret. But you now want to
* have a key that we derive from this secret. We need to do this because
* the shared secret may have different structure or size. */
uint32_t *derive_key(uint32_t shared_secret);
4. Man-in-the-Middle (MitM). Deși atacatorul nu poate determina cheia schimbată cu
DH, el poate altera mesajele din tranzit, încât să transmită valori derivate din
propriul secret. Atfel, el poate să facă două instanțe de DH: una cu Alice și
una cu Bob; în urma cărora va împărți cu fiecare câte un secret.
Astfel, atacatorul poate decripta mesajele de la Alice, le poate citi, și apoi
cripta la loc pentru a i le trimite lui Bob; compromițând astfel comunicarea
dintre cei doi fără a fi detectat.
Modificați atacatorul astfel încât să modifice mesajele în tranzit și să ajungă
să facă DH cu Alice și Bob; apoi folosiți cheile obținute pentru a decripta
mesajele transmise. Pentru acest exercitiu vom lucra in attacker.c.
Pentru a a preveni MitM în internet, e nevoie ca valorile publice interschimbate să fie însoțite de un certificat semnat de o entitate de încredere.
Lectură laborator
De parcurs înainte de laborator:
Transport Layer Security (TLS)
TLS este un protocol ce implementeaza partea de criptare discutata in laboratorul anterior. O sesiune TLS funcționează peste o conexiune TCP. TLS este responsabil pentru criptarea și autentificarea pachetelor schimbate de protocolul de nivel aplicație, în timp ce TCP asigură livrarea fiabilă a acestui flux de octeți criptat și autentificat. TLS este utilizat de multe protocoale diferite de nivel aplicație, cel mai cunoscut fiind HTTP (HTTP peste TLS este numit HTTPS).
Ilustratia de mai jos prezinta mesajele folosite in protocolul TLS.
OpenSSL
Cea mai populara si raspandita implementare de TLS se gaseste in
OpenSSL. OpenSSL este atat o biblioteca ce implementeaza
protocolul cat si un executabil openssl -v ce ofera diferite functionalitati precum criptarea
sau generarea perechilor de chei publice/private.
sudo apt install libssl-dev
De exemplu, pentru a genera o pereche de chei RSA, putem rula:
openssl genrsa -aes128 -out mykey.key 1024
In schimb, din C avem functii precum BIO_new_connect care face conexiunea TLS
cu un server. Peste acest API de C intalnim wrappere in alte limbaje de
programare.
bio = BIO_new_connect("hostname:port");
TLS Handshake
Înainte ca un client și un server să poată comunica în siguranță, trebuie stabilite mai întâi mai multe aspecte, inclusiv algoritmul de criptare și cheia care vor fi folosite, algoritmul de Message Autenthification Code (MAC) care va fi folosit, algoritmul care ar trebui folosit pentru schimbul de chei, etc. Acești parametri criptografici trebuie să fie conveniți de către client și server. Acesta este scopul principal al protocolului de strângere de mână TLS (TLS Handshake Protocol). În această sarcină, ne concentrăm pe protocolul de handshake TLS.
Stabilire Handshake
Următorul exemplu de cod inițiază o strângere de mână TLS cu un server TLS (numele serverului trebuie specificat ca prim argument al liniei de comandă). Putem observa cum prima data este deschisa o conexiune TCP peste care urmeaza sa folosim protocolul TLS.
#!/usr/bin/python3
import socket, ssl, sys, pprint
# Primim ca argument hostname-ul serverului, de exemplu google.com
hostname = sys.argv[1]
port = 443
# Create TCP connection
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.connect((hostname, port))
input("After making TCP connection. Press any key to continue ...")
# You may need to change this depending on your Linux distro
cadir = '/etc/ssl/certs'
# Set up the TLS context
context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
#context = ssl.SSLContext(ssl.PROTOCOL_TLSv1_2) # For Ubuntu 16.04
context.load_verify_locations(capath=cadir)
context.verify_mode = ssl.CERT_REQUIRED
context.check_hostname = True
# Add the TLS
ssock = context.wrap_socket(sock, server_hostname=hostname,
do_handshake_on_connect=False)
ssock.do_handshake() # Start the handshake
pprint.pprint(ssock.getpeercert())
input("After handshake. Press any key to continue ...")
# Close the TLS Connection
ssock.shutdown(socket.SHUT_RDWR)
ssock.close()
Exercitiu 1. Folosind condul precedent, stabiliti o conexiune catre google.com si
raspundeti la urmatoarele intrebari:
- Care este algoritmul de criptare folosit?
- Ce reprezinta
/etc/ssl/certs? - Afisati certificatul serverului.
- Utilizați Wireshark pentru a captura traficul de rețea în timpul executării programului și explicați observațiile ce se intampla. În special, explicați care pas declanșează handshake-ul TCP, și care pas declanșează handshake-ul TLS. Explicați relația dintre cele doua handshake-uri.
Creearea unui certificat
Pentru a actiona ca un root CA, va trebui sa creeam certificatul. Fisierul ca.key
contine cheia privata a CA-ului, pe cand ca.crt este certificatul public.
openssl req -new -x509 -keyout ca.key -out ca.crt
Va trebui sa completati toate campurile. La domain puneti
upb-cert-sign.com
Acum putem actiona ca un root CA, suntem gata să semnăm certificate digitale pentru clienții noștri.
Primul nostru client este o companie ce vrea sa administreze domeniul
vine-vacanta.com. Pentru ca această companie să obțină un certificat digital de
la o Autoritate de Certificare, trebuie să parcurgă trei pași.
-
Generarea unei perechi de chei publica/privata. Compania trebuie mai întâi să-și creeze propria pereche de chei public/privat. Putem rula următoarea comandă pentru a genera o pereche de chei RSA (atât cheia privată, cât și cea publică). De asemenea, vi se va solicita să furnizați o parolă pentru a cripta cheia privată (folosind algoritmul de criptare AES-128, așa cum este specificat în opțiunea de comandă). Cheile vor fi stocate în fișierul server.key.
$ openssl genrsa -aes128 -out server.key 2048Pentru a vedea continutul in format text a lui server.key puteti folosi comanda
openssl rsa -in server.key -text. -
Generarea unui Certificate Signing Request (CSR). Odată ce compania are fișierul cheie, ar trebui să genereze o Cerere de Semnare a Certificatului (CSR), care include în esență cheia publică a companiei. CSR va fi trimisă Autorității de Certificare, care va genera un certificat pentru cheie (de obicei, după ce se asigură că informațiile de identitate din CSR corespund cu adevărata identitate a serverului). Vom folosi
vine-vacanta.comca nume comun al cererii de certificat.$ openssl req -new -key server.key -out server.csrVa trebui sa completati toate campurile.
-
Generarea certificatelor. Fișierul CSR trebuie să aibă semnătura Autorității de Certificare pentru a forma un certificat. În lumea reală, fișierele CSR sunt de obicei trimise unei Autorități de Certificare de încredere pentru semnătura lor. În acest laborator, vom folosi propria noastră Autoritate de Certificare de încredere pentru a genera certificate. Următoarea comandă transformă cererea de semnătură a certificatului (server.csr) într-un certificat X509 (server.crt), folosind ca.crt și ca.key ale Autorității de Certificare:
$ sudo openssl ca -in server.csr -out server.crt -cert ca.crt -keyfile ca.keyCel mai probabil o sa primiti o eroare legata de lipsa unui fisier
index.txtsiserial. Va trebui sa le creati astfel (atentie, directorul poate fi diferit)sudo touch /etc/pki/CA/index.txt sudo echo "1000" > /etc/pki/CA/serialPe unele versiuni de Ubuntu/Debian, OpenSSL se așteaptă ca aceste fișiere să existe sub directorul
demoCAdin folderul curent, iar în el să existe subdirectorulnewcerts. În acest caz, problema se rezolva cu următoarele comenzi:mkdir -p demoCA/newcerts touch demoCA/index.txt echo "1000" > demoCA/serialPână la acest moment, ierarhia de fișiere ar trebui să arate ca mai jos:
dorinel@new-buntu:~/pki$ tree -F ./ ├── ca.crt ├── ca.key ├── demoCA/ │ ├── index.txt │ ├── newcerts/ │ └── serial ├── server.csr └── server.key 3 directories, 6 files
Exercitiu 3. Generati un certificat pentru domeniul vine-vacanta.com. Vom adauga
urmatoarea intrare in /etc/hosts pentru a il face pe Linux sa faca legatura
dintre vine-vacanta.com si 127.0.0.1.
Pentru a putea folosi certificatul in directorul curent, vom rula:
openssl x509 -in server.crt -noout -subject_hash
127.0.0.1 vine-vacanta.com
Server HTTPS
In acest exercitiu vom dezvolta un server de HTTPS. Pentru a realiza acest
lucru va trebui sa avem la dispozitie certificatele create la exercitiul
anterior pe care sa le trimitem clientilor.
#!/usr/bin/python3
import socket
import ssl
html = """HTTP/1.1 200 OK\r\nContent-Type: text/html\r\n\r\n<!DOCTYPE html><html><body><h1>Hello, world!</h1></body></html>"""
SERVER_CERT = './server.crt'
SERVER_PRIVATE = './server.key'
context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
#context = ssl.SSLContext(ssl.PROTOCOL_TLSv1_2) # Ubuntu 16.04
context.load_cert_chain(SERVER_CERT, SERVER_PRIVATE)
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM, 0)
sock.bind(('0.0.0.0', 4433))
sock.listen(5)
while True:
newsock, fromaddr = sock.accept()
ssock = context.wrap_socket(newsock, server_side=True)
data = ssock.recv(1024) # Read data over TLS
ssock.sendall(html.encode('utf-8')) # Send data over TLS
ssock.shutdown(socket.SHUT_RDWR) # Close the TLS connection
ssock.close()
Exercitiu 4. Creeati un server HTTPS care reprezinta pagina principala a
site-ului vine-vacanta.com. Vom folosi wget sau curl pentru a testa implementarea.
sudo curl --cacert ca.crt https://vine-vacanta.com:4433
Pentru a nu rula serverul cu sudo, nu vom folosi protul
443folosit implicit de HTTPS. Din cod observam ca folosim portul 4433 pentru server, asa ca o sa rulam cu vine-vacanta.com:4433
Exercitiu 5. În aceast exercitiu, vom testa implementarea serverului nostru de HTTPS
folosind un browser, cum ar fi Firefox. În primul rând, vom accesa vine-vacanta.com din
browserul. Ce se inampla?
Pentru ca un browser sa poata stabili o conexiune cu serverul HTTPS, acesta trebuie să verifice certificatul. Trebuie să aiba certificatul CA-ului care a emis certificatul serverului. In cazul nostru, CA authority este creat în de noi si browserul nu o are în lista sa de certificate de încredere. Trebuie să adăugăm manual certificatul CA-ului nostru în lista de certificate ale browserului. Pentru browserul Firefox, faceți clic pe următoarea secvență de meniu:
Edit -> Preference -> Privacy & Security -> View Certificates
Veți vedea o listă de certificate care sunt deja acceptate de Firefox. De aici, putem "importa" propriul nostru certificat. Vă rugăm să importați ca.crt și să selectați următoarea opțiune: "Trust this CA to identify web sites". Veți vedea că certificatul Autorității noastre de Certificare se află acum în lista Firefox a certificatelor acceptate.
Exercitiu 6. Conectativa in aceasi retea cu mai multi colegi. Unul dintre voi va rula serverul, iar ceilalti il vor putea accesa din browser.
Va trebui sa modificati adresa IP a lui
vine-vacanta.comin functie de cine ruleaza serverul.